Skip to main content

alef_e2e/codegen/
rust.rs

1//! Rust e2e test code generator.
2//!
3//! Generates `e2e/rust/Cargo.toml` and `tests/{category}_test.rs` files from
4//! JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::codegen::client::{self, CallCtx};
7use crate::codegen::resolve_field;
8use crate::config::E2eConfig;
9use crate::escape::{escape_rust, rust_raw_string, sanitize_filename, sanitize_ident};
10use crate::field_access::FieldResolver;
11use crate::fixture::{
12    Assertion, CallbackAction, CorsConfig, Fixture, FixtureGroup, HttpFixture, StaticFilesConfig,
13    ValidationErrorExpectation,
14};
15use alef_core::backend::GeneratedFile;
16use alef_core::config::AlefConfig;
17use alef_core::hash::{self, CommentStyle};
18use alef_core::template_versions as tv;
19use anyhow::Result;
20use std::fmt::Write as FmtWrite;
21use std::path::PathBuf;
22
23/// Rust e2e test code generator.
24pub struct RustE2eCodegen;
25
26impl super::E2eCodegen for RustE2eCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        alef_config: &AlefConfig,
32    ) -> Result<Vec<GeneratedFile>> {
33        let mut files = Vec::new();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
35
36        // Resolve crate name and path from config.
37        let crate_name = resolve_crate_name(e2e_config, alef_config);
38        let crate_path = resolve_crate_path(e2e_config, &crate_name);
39        let dep_name = crate_name.replace('-', "_");
40
41        // Cargo.toml
42        // Check if any call config (default or named) uses json_object/handle args (needs serde_json dep).
43        let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
44        let needs_serde_json = all_call_configs
45            .flat_map(|c| c.args.iter())
46            .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
47
48        // Check if any fixture in any group requires a mock HTTP server.
49        // This includes both `mock_response`-bearing fixtures and `http`-block fixtures.
50        let needs_mock_server = groups
51            .iter()
52            .flat_map(|g| g.fixtures.iter())
53            .any(|f| !is_skipped(f, "rust") && f.mock_response.is_some());
54
55        // Check if any fixture uses the http integration test pattern (an `http` block).
56        let needs_http_tests = groups
57            .iter()
58            .flat_map(|g| g.fixtures.iter())
59            .any(|f| !is_skipped(f, "rust") && f.http.is_some());
60
61        // Check if any http fixture uses CORS or static-files middleware (needs tower-http).
62        let needs_tower_http = groups
63            .iter()
64            .flat_map(|g| g.fixtures.iter())
65            .filter(|f| !is_skipped(f, "rust"))
66            .filter_map(|f| f.http.as_ref())
67            .filter_map(|h| h.handler.middleware.as_ref())
68            .any(|m| m.cors.is_some() || m.static_files.is_some());
69
70        // Tokio is needed when any test is async (mock server, http tests, or async call config).
71        let any_async_call = std::iter::once(&e2e_config.call)
72            .chain(e2e_config.calls.values())
73            .any(|c| c.r#async);
74        let needs_tokio = needs_mock_server || needs_http_tests || any_async_call;
75
76        let crate_version = resolve_crate_version(e2e_config).or_else(|| alef_config.resolved_version());
77        files.push(GeneratedFile {
78            path: output_base.join("Cargo.toml"),
79            content: render_cargo_toml(
80                &crate_name,
81                &dep_name,
82                &crate_path,
83                needs_serde_json,
84                needs_mock_server,
85                needs_http_tests,
86                needs_tokio,
87                needs_tower_http,
88                e2e_config.dep_mode,
89                crate_version.as_deref(),
90                &alef_config.crate_config.features,
91            ),
92            generated_header: true,
93        });
94
95        // Generate mock_server.rs when at least one fixture uses mock_response.
96        if needs_mock_server {
97            files.push(GeneratedFile {
98                path: output_base.join("tests").join("mock_server.rs"),
99                content: render_mock_server_module(),
100                generated_header: true,
101            });
102        }
103        // Always generate standalone mock-server binary for cross-language e2e suites
104        // when any fixture has http data (serves fixture responses for non-Rust tests).
105        if needs_mock_server || needs_http_tests {
106            files.push(GeneratedFile {
107                path: output_base.join("src").join("main.rs"),
108                content: render_mock_server_binary(),
109                generated_header: true,
110            });
111        }
112
113        // Per-category test files.
114        for group in groups {
115            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
116
117            if fixtures.is_empty() {
118                continue;
119            }
120
121            let filename = format!("{}_test.rs", sanitize_filename(&group.category));
122            let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
123
124            files.push(GeneratedFile {
125                path: output_base.join("tests").join(filename),
126                content,
127                generated_header: true,
128            });
129        }
130
131        Ok(files)
132    }
133
134    fn language_name(&self) -> &'static str {
135        "rust"
136    }
137}
138
139// ---------------------------------------------------------------------------
140// Config resolution helpers
141// ---------------------------------------------------------------------------
142
143fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
144    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
145    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
146    // import identifier, not the Cargo package name.
147    alef_config.crate_config.name.clone()
148}
149
150fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
151    e2e_config
152        .resolve_package("rust")
153        .and_then(|p| p.path.clone())
154        .unwrap_or_else(|| format!("../../crates/{crate_name}"))
155}
156
157fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
158    e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
159}
160
161fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
162    call_config
163        .overrides
164        .get("rust")
165        .and_then(|o| o.function.clone())
166        .unwrap_or_else(|| call_config.function.clone())
167}
168
169fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
170    resolve_module_for_call(&e2e_config.call, dep_name)
171}
172
173fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
174    // For Rust, the module name is the crate identifier (underscores).
175    // Priority: override.crate_name > override.module > dep_name
176    let overrides = call_config.overrides.get("rust");
177    overrides
178        .and_then(|o| o.crate_name.clone())
179        .or_else(|| overrides.and_then(|o| o.module.clone()))
180        .unwrap_or_else(|| dep_name.to_string())
181}
182
183fn is_skipped(fixture: &Fixture, language: &str) -> bool {
184    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
185}
186
187// ---------------------------------------------------------------------------
188// Rendering
189// ---------------------------------------------------------------------------
190
191#[allow(clippy::too_many_arguments)]
192pub fn render_cargo_toml(
193    crate_name: &str,
194    dep_name: &str,
195    crate_path: &str,
196    needs_serde_json: bool,
197    needs_mock_server: bool,
198    needs_http_tests: bool,
199    needs_tokio: bool,
200    needs_tower_http: bool,
201    dep_mode: crate::config::DependencyMode,
202    version: Option<&str>,
203    features: &[String],
204) -> String {
205    let e2e_name = format!("{dep_name}-e2e-rust");
206    // Use only the features explicitly configured in alef.toml.
207    // Do NOT auto-add "serde" — the target crate may not have that feature.
208    // serde_json is added as a separate dependency when needed.
209    let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
210    let features_str = if effective_features.is_empty() {
211        String::new()
212    } else {
213        format!(", default-features = false, features = {:?}", effective_features)
214    };
215    let dep_spec = match dep_mode {
216        crate::config::DependencyMode::Registry => {
217            let ver = version.unwrap_or("0.1.0");
218            if crate_name != dep_name {
219                format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
220            } else if effective_features.is_empty() {
221                format!("{dep_name} = \"{ver}\"")
222            } else {
223                format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
224            }
225        }
226        crate::config::DependencyMode::Local => {
227            if crate_name != dep_name {
228                format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
229            } else if effective_features.is_empty() {
230                format!("{dep_name} = {{ path = \"{crate_path}\" }}")
231            } else {
232                format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
233            }
234        }
235    };
236    // serde_json is needed either when args use json_object/handle, or when the
237    // mock server binary is present (it uses serde_json::Value for fixture bodies),
238    // or when http integration tests are generated (they serialize fixture bodies).
239    let effective_needs_serde_json = needs_serde_json || needs_mock_server || needs_http_tests;
240    let serde_line = if effective_needs_serde_json {
241        "\nserde_json = \"1\""
242    } else {
243        ""
244    };
245    // An empty `[workspace]` table makes the e2e crate its own workspace root, so
246    // it never gets pulled into a parent crate's workspace. This means consumers
247    // don't have to remember to add `e2e/rust` to `workspace.exclude`, and
248    // `cargo fmt`/`cargo build` work the same whether the parent has a
249    // workspace or not.
250    // Mock server requires axum (HTTP router) and tokio-stream (SSE streaming).
251    // The standalone binary additionally needs serde (derive) and walkdir.
252    // Http integration tests require axum-test for the test server.
253    let needs_axum = needs_mock_server || needs_http_tests;
254    let mock_lines = if needs_axum {
255        let mut lines = format!(
256            "\naxum = \"{axum}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
257            axum = tv::cargo::AXUM,
258            walkdir = tv::cargo::WALKDIR,
259        );
260        if needs_mock_server {
261            lines.push_str(&format!(
262                "\ntokio-stream = \"{tokio_stream}\"",
263                tokio_stream = tv::cargo::TOKIO_STREAM
264            ));
265        }
266        if needs_http_tests {
267            lines.push_str("\naxum-test = \"20\"\nbytes = \"1\"");
268        }
269        if needs_tower_http {
270            lines.push_str(&format!(
271                "\ntower-http = {{ version = \"{tower_http}\", features = [\"cors\", \"fs\"] }}\ntempfile = \"{tempfile}\"",
272                tower_http = tv::cargo::TOWER_HTTP,
273                tempfile = tv::cargo::TEMPFILE,
274            ));
275        }
276        lines
277    } else {
278        String::new()
279    };
280    let mut machete_ignored: Vec<&str> = Vec::new();
281    if effective_needs_serde_json {
282        machete_ignored.push("\"serde_json\"");
283    }
284    if needs_axum {
285        machete_ignored.push("\"axum\"");
286        machete_ignored.push("\"serde\"");
287        machete_ignored.push("\"walkdir\"");
288    }
289    if needs_mock_server {
290        machete_ignored.push("\"tokio-stream\"");
291    }
292    if needs_http_tests {
293        machete_ignored.push("\"axum-test\"");
294        machete_ignored.push("\"bytes\"");
295    }
296    if needs_tower_http {
297        machete_ignored.push("\"tower-http\"");
298        machete_ignored.push("\"tempfile\"");
299    }
300    let machete_section = if machete_ignored.is_empty() {
301        String::new()
302    } else {
303        format!(
304            "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
305            machete_ignored.join(", ")
306        )
307    };
308    let tokio_line = if needs_tokio {
309        "\ntokio = { version = \"1\", features = [\"full\"] }"
310    } else {
311        ""
312    };
313    let bin_section = if needs_mock_server || needs_http_tests {
314        "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
315    } else {
316        ""
317    };
318    let header = hash::header(CommentStyle::Hash);
319    format!(
320        r#"{header}
321[workspace]
322
323[package]
324name = "{e2e_name}"
325version = "0.1.0"
326edition = "2021"
327license = "MIT"
328publish = false
329{bin_section}
330[dependencies]
331{dep_spec}{serde_line}{mock_lines}{tokio_line}
332{machete_section}"#
333    )
334}
335
336fn render_test_file(
337    category: &str,
338    fixtures: &[&Fixture],
339    e2e_config: &E2eConfig,
340    dep_name: &str,
341    needs_mock_server: bool,
342) -> String {
343    let mut out = String::new();
344    out.push_str(&hash::header(CommentStyle::DoubleSlash));
345    let _ = writeln!(out, "//! E2e tests for category: {category}");
346    let _ = writeln!(out);
347
348    let module = resolve_module(e2e_config, dep_name);
349    let field_resolver = FieldResolver::new(
350        &e2e_config.fields,
351        &e2e_config.fields_optional,
352        &e2e_config.result_fields,
353        &e2e_config.fields_array,
354    );
355
356    // Check if this file has http-fixture tests (separate from call-based tests).
357    let file_has_http = fixtures.iter().any(|f| f.http.is_some());
358    // Call-based: any non-http fixture whose resolved call config has a function name.
359    // This covers both mock-server-driven fixtures (liter-llm) and plain function-call
360    // fixtures (kreuzberg::extract_file, etc.). Pure stub fixtures (no callable function
361    // configured) use a stub path — no function import.
362    let is_call_based = |f: &Fixture| -> bool {
363        if f.http.is_some() {
364            return false;
365        }
366        let cc = e2e_config.resolve_call(f.call.as_deref());
367        !resolve_function_name_for_call(cc).is_empty()
368    };
369    let file_has_call_based = fixtures.iter().any(|f| is_call_based(f));
370
371    // Collect all unique (module, function) pairs needed across call-based fixtures only.
372    // Http fixtures and stub fixtures use different code paths and don't import the call function.
373    if file_has_call_based {
374        let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
375        for fixture in fixtures.iter().filter(|f| is_call_based(f)) {
376            let call_config = e2e_config.resolve_call(fixture.call.as_deref());
377            let fn_name = resolve_function_name_for_call(call_config);
378            let mod_name = resolve_module_for_call(call_config, dep_name);
379            imported.insert((mod_name, fn_name));
380        }
381        // Emit use statements, grouping by module when possible.
382        let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
383        for (mod_name, fn_name) in &imported {
384            by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
385        }
386        for (mod_name, fns) in &by_module {
387            if fns.len() == 1 {
388                let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
389            } else {
390                let joined = fns.join(", ");
391                let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
392            }
393        }
394    }
395
396    // Http fixtures use App + RequestContext for integration tests.
397    if file_has_http {
398        let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
399    }
400
401    // Import handle constructor functions and the config type they use.
402    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
403    if has_handle_args {
404        let _ = writeln!(out, "use {module}::CrawlConfig;");
405    }
406    for arg in &e2e_config.call.args {
407        if arg.arg_type == "handle" {
408            use heck::ToSnakeCase;
409            let constructor_name = format!("create_{}", arg.name.to_snake_case());
410            let _ = writeln!(out, "use {module}::{constructor_name};");
411        }
412    }
413
414    // Import mock_server module when any fixture in this file uses mock_response.
415    let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.mock_response.is_some());
416    if file_needs_mock {
417        let _ = writeln!(out, "mod mock_server;");
418        let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
419    }
420
421    // When the Rust override uses wrap_options_in_some + options_type, the
422    // generated tests annotate `serde_json::from_value::<OptionsType>(...)`.
423    // Import the options type so the annotation resolves.
424    {
425        let call_config = &e2e_config.call;
426        let rust_override = call_config.overrides.get("rust");
427        let needs_options_type_import =
428            rust_override.is_some_and(|o| o.wrap_options_in_some && o.options_type.is_some());
429        if needs_options_type_import {
430            let ty_name = rust_override.unwrap().options_type.as_deref().unwrap();
431            let file_has_non_null_options = fixtures.iter().any(|f| {
432                call_config
433                    .args
434                    .iter()
435                    .any(|a| a.arg_type == "json_object" && a.optional && !resolve_field(&f.input, &a.field).is_null())
436            });
437            if file_has_non_null_options {
438                let _ = writeln!(out, "use {module}::{ty_name};");
439            }
440        }
441    }
442
443    // Import the visitor trait, result enum, and node context when any fixture
444    // in this file declares a `visitor` block. Without these, the inline
445    // `impl HtmlVisitor for _TestVisitor` block fails to resolve.
446    let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
447    if file_needs_visitor {
448        let visitor_trait = resolve_visitor_trait(&module);
449        let trait_module = resolve_visitor_trait_module(&module);
450        if trait_module == module {
451            let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
452        } else {
453            // Trait lives in a sub-module; emit separate imports.
454            let _ = writeln!(out, "use {trait_module}::{visitor_trait};");
455            let _ = writeln!(out, "use {module}::{{NodeContext, VisitResult}};");
456        }
457    }
458
459    let _ = writeln!(out);
460
461    for fixture in fixtures {
462        render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
463        let _ = writeln!(out);
464    }
465
466    if !out.ends_with('\n') {
467        out.push('\n');
468    }
469    out
470}
471
472fn render_test_function(
473    out: &mut String,
474    fixture: &Fixture,
475    e2e_config: &E2eConfig,
476    dep_name: &str,
477    field_resolver: &FieldResolver,
478) {
479    // Http fixtures get their own integration test code path.
480    if fixture.http.is_some() {
481        render_http_test_function(out, fixture, dep_name);
482        return;
483    }
484
485    let fn_name = sanitize_ident(&fixture.id);
486    let description = &fixture.description;
487    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
488    let function_name = resolve_function_name_for_call(call_config);
489    let module = resolve_module_for_call(call_config, dep_name);
490
491    // Stub fixtures that have no http, no mock_response, AND no callable function
492    // configured (e.g., schema/spec validation fixtures: asyncapi, grpc, graphql_schema).
493    // These libs don't yet have a callable Rust API — emit a passing stub.
494    if fixture.http.is_none() && fixture.mock_response.is_none() && function_name.is_empty() {
495        let _ = writeln!(out, "#[tokio::test]");
496        let _ = writeln!(out, "async fn test_{fn_name}() {{");
497        let _ = writeln!(out, "    // {description}");
498        let _ = writeln!(
499            out,
500            "    // TODO: implement when a callable API is available for this fixture type."
501        );
502        let _ = writeln!(out, "}}");
503        return;
504    }
505    let result_var = &call_config.result_var;
506    let has_mock = fixture.mock_response.is_some();
507
508    // Tests with a mock server are always async (Axum requires a Tokio runtime).
509    let is_async = call_config.r#async || has_mock;
510    if is_async {
511        let _ = writeln!(out, "#[tokio::test]");
512        let _ = writeln!(out, "async fn test_{fn_name}() {{");
513    } else {
514        let _ = writeln!(out, "#[test]");
515        let _ = writeln!(out, "fn test_{fn_name}() {{");
516    }
517    let _ = writeln!(out, "    // {description}");
518
519    // Emit mock server setup before building arguments so arg expressions can
520    // reference `mock_server.url` when needed.
521    if has_mock {
522        render_mock_server_setup(out, fixture, e2e_config);
523    }
524
525    // Check if any assertion is an error assertion.
526    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
527
528    // Resolve Rust-specific overrides for argument shaping.
529    let rust_overrides = call_config.overrides.get("rust");
530    let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
531    let options_type = rust_overrides.and_then(|o| o.options_type.as_deref());
532    let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
533
534    // Emit input variable bindings from args config.
535    let mut arg_exprs: Vec<String> = Vec::new();
536    for arg in &call_config.args {
537        let value = resolve_field(&fixture.input, &arg.field);
538        let var_name = &arg.name;
539        let (bindings, expr) = render_rust_arg(
540            var_name,
541            value,
542            &arg.arg_type,
543            arg.optional,
544            &module,
545            &fixture.id,
546            if has_mock {
547                Some("mock_server.url.as_str()")
548            } else {
549                None
550            },
551            arg.owned,
552            arg.element_type.as_deref(),
553        );
554        // For functions whose options slot is owned `Option<T>` rather than `&T`,
555        // wrap the json_object expression in `Some(...).clone()` so it matches
556        // the parameter shape.  When the fixture has no options (null value),
557        // the binding is `Default::default()` whose type Rust cannot infer from
558        // `Some(x.clone())` alone — emit `None` directly and suppress the binding.
559        let final_expr = if wrap_options_in_some && arg.arg_type == "json_object" {
560            if value.is_null() && arg.optional {
561                // No options provided — pass None directly; bindings unused, don't emit.
562                "None".to_string()
563            } else {
564                // Emit bindings, annotating the deserialization call with the
565                // concrete type so Rust can infer it without the `Some(T)` wrapper
566                // context (which is only available after the binding is created).
567                for binding in &bindings {
568                    let annotated = if let Some(ty) = options_type {
569                        // Add turbofish to `serde_json::from_value(...)` → `serde_json::from_value::<Ty>(...)`
570                        binding.replace("serde_json::from_value(", &format!("serde_json::from_value::<{ty}>("))
571                    } else {
572                        binding.clone()
573                    };
574                    let _ = writeln!(out, "    {annotated}");
575                }
576                if let Some(rest) = expr.strip_prefix('&') {
577                    format!("Some({rest}.clone())")
578                } else {
579                    format!("Some({expr})")
580                }
581            }
582        } else {
583            for binding in &bindings {
584                let _ = writeln!(out, "    {binding}");
585            }
586            expr
587        };
588        arg_exprs.push(final_expr);
589    }
590
591    // Emit visitor if present in fixture.
592    if let Some(visitor_spec) = &fixture.visitor {
593        let _ = writeln!(out, "    #[derive(Debug)]");
594        let _ = writeln!(out, "    struct _TestVisitor;");
595        let _ = writeln!(out, "    impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
596        for (method_name, action) in &visitor_spec.callbacks {
597            emit_rust_visitor_method(out, method_name, action);
598        }
599        let _ = writeln!(out, "    }}");
600        let _ = writeln!(
601            out,
602            "    let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
603        );
604        arg_exprs.push("Some(visitor)".to_string());
605    } else {
606        // No fixture-supplied visitor: append any extra positional args declared in
607        // the rust override (e.g. trailing `None` for an Option<VisitorParam> slot).
608        arg_exprs.extend(extra_args);
609    }
610
611    let args_str = arg_exprs.join(", ");
612
613    let await_suffix = if is_async { ".await" } else { "" };
614
615    let result_is_tree = call_config.result_var == "tree";
616    // Result-shape flags describe the Rust core's return type and apply to every
617    // binding equally. Prefer call-level values; fall back to per-language
618    // overrides for backwards compatibility with older alef.tomls.
619    let result_is_simple = call_config.result_is_simple || rust_overrides.is_some_and(|o| o.result_is_simple);
620    let result_is_vec = call_config.result_is_vec || rust_overrides.is_some_and(|o| o.result_is_vec);
621    // When result_is_option is set, the function returns Option<T>. Field-path
622    // assertions unwrap first via `.as_ref().expect("Option should be Some")`.
623    let result_is_option = call_config.result_is_option || rust_overrides.is_some_and(|o| o.result_is_option);
624
625    if has_error_assertion {
626        let _ = writeln!(out, "    let {result_var} = {function_name}({args_str}){await_suffix};");
627        // Render error assertions.
628        for assertion in &fixture.assertions {
629            render_assertion(
630                out,
631                assertion,
632                result_var,
633                &module,
634                dep_name,
635                true,
636                &[],
637                field_resolver,
638                result_is_tree,
639                result_is_simple,
640                false,
641                false,
642            );
643        }
644        let _ = writeln!(out, "}}");
645        return;
646    }
647
648    // Non-error path: unwrap the result.
649    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
650
651    // Check if any assertion actually uses the result variable.
652    // If all assertions are skipped (field not on result type), use `_` to avoid
653    // Rust's "variable never used" warning.
654    let has_usable_assertion = fixture.assertions.iter().any(|a| {
655        if a.assertion_type == "not_error" || a.assertion_type == "error" {
656            return false;
657        }
658        if a.assertion_type == "method_result" {
659            // method_result assertions that would generate only a TODO comment don't use the
660            // result variable. These are: missing `method` field, or unsupported `check` type.
661            let supported_checks = [
662                "equals",
663                "is_true",
664                "is_false",
665                "greater_than_or_equal",
666                "count_min",
667                "is_error",
668                "contains",
669                "not_empty",
670                "is_empty",
671            ];
672            let check = a.check.as_deref().unwrap_or("is_true");
673            if a.method.is_none() || !supported_checks.contains(&check) {
674                return false;
675            }
676        }
677        match &a.field {
678            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
679            _ => true,
680        }
681    });
682
683    let result_binding = if has_usable_assertion {
684        result_var.to_string()
685    } else {
686        "_".to_string()
687    };
688
689    // Detect Option-returning functions: only skip unwrap when ALL assertions are
690    // pure emptiness/bool checks with NO field access (is_none/is_some on the result itself).
691    // If any assertion accesses a field (e.g. `html`), we need the inner value, so unwrap.
692    let has_field_access = fixture
693        .assertions
694        .iter()
695        .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
696    let only_emptiness_checks = !has_field_access
697        && fixture.assertions.iter().all(|a| {
698            matches!(
699                a.assertion_type.as_str(),
700                "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
701            )
702        });
703
704    // Per-rust override of the call-level `returns_result`. When set, takes
705    // precedence over `CallConfig.returns_result` for the Rust generator only.
706    let returns_result = rust_overrides
707        .and_then(|o| o.returns_result)
708        .unwrap_or(call_config.returns_result);
709
710    let unwrap_suffix = if returns_result {
711        ".expect(\"should succeed\")"
712    } else {
713        ""
714    };
715    if only_emptiness_checks || !returns_result {
716        // Option-returning or non-Result-returning: bind raw value, no unwrap.
717        let _ = writeln!(
718            out,
719            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
720        );
721    } else if has_not_error || !fixture.assertions.is_empty() {
722        let _ = writeln!(
723            out,
724            "    let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
725        );
726    } else {
727        let _ = writeln!(
728            out,
729            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
730        );
731    }
732
733    // Emit Option field unwrap bindings for any fields accessed in assertions.
734    // Use FieldResolver to handle optional fields, including nested/aliased paths.
735    // Skipped when the call returns Vec<T>: per-element iteration is emitted by
736    // `render_assertion` itself, so the call-site has no single result struct
737    // to unwrap fields off of.
738    let string_assertion_types = [
739        "equals",
740        "contains",
741        "contains_all",
742        "contains_any",
743        "not_contains",
744        "starts_with",
745        "ends_with",
746        "min_length",
747        "max_length",
748        "matches_regex",
749    ];
750    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
751    if !result_is_vec {
752        for assertion in &fixture.assertions {
753            if let Some(f) = &assertion.field {
754                if !f.is_empty()
755                    && string_assertion_types.contains(&assertion.assertion_type.as_str())
756                    && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
757                {
758                    // Only unwrap optional string fields — numeric optionals (u64, usize)
759                    // don't support .as_deref() and should be compared directly.
760                    let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
761                    if !is_string_assertion {
762                        continue;
763                    }
764                    if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
765                        let _ = writeln!(out, "    {binding}");
766                        unwrapped_fields.push((f.clone(), local_var));
767                    }
768                }
769            }
770        }
771    }
772
773    // Render assertions.
774    for assertion in &fixture.assertions {
775        if assertion.assertion_type == "not_error" {
776            // Already handled by .expect() above.
777            continue;
778        }
779        render_assertion(
780            out,
781            assertion,
782            result_var,
783            &module,
784            dep_name,
785            false,
786            &unwrapped_fields,
787            field_resolver,
788            result_is_tree,
789            result_is_simple,
790            result_is_vec,
791            result_is_option,
792        );
793    }
794
795    let _ = writeln!(out, "}}");
796}
797
798// ---------------------------------------------------------------------------
799// Argument rendering
800// ---------------------------------------------------------------------------
801
802#[allow(clippy::too_many_arguments)]
803fn render_rust_arg(
804    name: &str,
805    value: &serde_json::Value,
806    arg_type: &str,
807    optional: bool,
808    module: &str,
809    fixture_id: &str,
810    mock_base_url: Option<&str>,
811    owned: bool,
812    element_type: Option<&str>,
813) -> (Vec<String>, String) {
814    if arg_type == "mock_url" {
815        let lines = vec![format!(
816            "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
817        )];
818        return (lines, format!("&{name}"));
819    }
820    // When the arg is a base_url and a mock server is running, use the mock server URL.
821    if arg_type == "base_url" {
822        if let Some(url_expr) = mock_base_url {
823            return (vec![], url_expr.to_string());
824        }
825        // No mock server: fall through to string handling below.
826    }
827    if arg_type == "handle" {
828        // Generate a create_engine (or equivalent) call and pass the config.
829        // If the fixture has input.config, serialize it as a json_object and pass it;
830        // otherwise pass None.
831        use heck::ToSnakeCase;
832        let constructor_name = format!("create_{}", name.to_snake_case());
833        let mut lines = Vec::new();
834        if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
835            lines.push(format!(
836                "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
837            ));
838        } else {
839            // Serialize the config JSON and deserialize at runtime.
840            let json_literal = serde_json::to_string(value).unwrap_or_default();
841            let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
842            lines.push(format!(
843                "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
844            ));
845            lines.push(format!(
846                "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
847            ));
848        }
849        return (lines, format!("&{name}"));
850    }
851    if arg_type == "json_object" {
852        return render_json_object_arg(name, value, optional, owned, element_type, module);
853    }
854    // bytes args carrying a string value need classification: a relative file path
855    // (e.g. "pdf/fake_memo.pdf") must be loaded from the test_documents directory
856    // at runtime, not embedded as a literal string. Inline text and base64 fall
857    // through to the default-literal path below (raw string + .as_bytes()).
858    if arg_type == "bytes" && !optional {
859        if let Some(raw) = value.as_str() {
860            if matches!(classify_bytes_value(raw), BytesKind::FilePath) {
861                let lines = vec![format!(
862                    "let {name} = std::fs::read(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../test_documents/\", \"{raw}\")).expect(\"fixture file should exist\");"
863                )];
864                let pass = if owned { name.to_string() } else { format!("&{name}") };
865                return (lines, pass);
866            }
867        }
868    }
869    if arg_type == "bytes" && optional {
870        if let Some(raw) = value.as_str() {
871            if matches!(classify_bytes_value(raw), BytesKind::FilePath) {
872                let lines = vec![format!(
873                    "let {name} = Some(std::fs::read(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../test_documents/\", \"{raw}\")).expect(\"fixture file should exist\"));"
874                )];
875                let pass = if owned {
876                    name.to_string()
877                } else {
878                    format!("{name}.as_deref().map(|v| v.as_slice())")
879                };
880                return (lines, pass);
881            }
882        }
883    }
884    if value.is_null() && !optional {
885        // Required arg with no fixture value: use a language-appropriate default.
886        let default_val = match arg_type {
887            "string" => "String::new()".to_string(),
888            "int" | "integer" => "0".to_string(),
889            "float" | "number" => "0.0_f64".to_string(),
890            "bool" | "boolean" => "false".to_string(),
891            _ => "Default::default()".to_string(),
892        };
893        // String args are passed by reference in Rust.
894        let expr = if arg_type == "string" {
895            format!("&{name}")
896        } else {
897            name.to_string()
898        };
899        return (vec![format!("let {name} = {default_val};")], expr);
900    }
901    let literal = json_to_rust_literal(value, arg_type);
902    // String args default to `&str` (raw string literal) and bytes default to `&[u8]`
903    // (via `.as_bytes()`). When the call config sets `owned = true`, the binding
904    // type and pass expression flip to owned (`String::from(...)` / `.as_bytes().to_vec()`)
905    // and the value is passed by value rather than reference.
906    let pass_by_ref = arg_type == "bytes";
907    let optional_expr = |n: &str| {
908        if arg_type == "string" {
909            if owned {
910                n.to_string()
911            } else {
912                format!("{n}.as_deref()")
913            }
914        } else if arg_type == "bytes" {
915            if owned {
916                n.to_string()
917            } else {
918                format!("{n}.as_deref().map(|v| v.as_slice())")
919            }
920        } else {
921            // Owned numeric / bool / generic: pass the Option<T> by value.
922            // Function signature shape `Option<T>` matches without `.as_ref()`,
923            // which would produce `Option<&T>` and fail to coerce.
924            n.to_string()
925        }
926    };
927    let expr = |n: &str| {
928        if arg_type == "bytes" {
929            if owned {
930                // Bytes literal `let n = r#"..."#;` is `&'static str`. To produce
931                // `Vec<u8>` we go through `.as_bytes().to_vec()`. (For file-path
932                // bytes the early-return path already produces a `Vec<u8>` binding,
933                // so this branch only fires for inline text/base64 strings.)
934                format!("{n}.as_bytes().to_vec()")
935            } else {
936                format!("{n}.as_bytes()")
937            }
938        } else if arg_type == "string" {
939            if owned {
940                format!("{n}.to_string()")
941            } else {
942                n.to_string()
943            }
944        } else if pass_by_ref {
945            format!("&{n}")
946        } else {
947            n.to_string()
948        }
949    };
950    if optional && value.is_null() {
951        let none_decl = match arg_type {
952            "string" if owned => format!("let {name}: Option<String> = None;"),
953            "string" => format!("let {name}: Option<String> = None;"),
954            "bytes" if owned => format!("let {name}: Option<Vec<u8>> = None;"),
955            "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
956            _ => format!("let {name} = None;"),
957        };
958        (vec![none_decl], optional_expr(name))
959    } else if optional {
960        // For owned bytes optional, wrap as Some(Vec<u8>) so the call passes Option<Vec<u8>>.
961        if arg_type == "bytes" && owned {
962            (
963                vec![format!("let {name} = Some(({literal}).as_bytes().to_vec());")],
964                optional_expr(name),
965            )
966        } else if arg_type == "string" && owned {
967            (
968                vec![format!("let {name} = Some(({literal}).to_string());")],
969                optional_expr(name),
970            )
971        } else {
972            (vec![format!("let {name} = Some({literal});")], optional_expr(name))
973        }
974    } else {
975        (vec![format!("let {name} = {literal};")], expr(name))
976    }
977}
978
979/// How to represent a fixture `type = "bytes"` string value in generated Rust.
980///
981/// Mirrors the python codegen's classification — same rules, different emit. See
982/// `codegen::python::classify_bytes_value` for the detailed rule order.
983#[derive(Debug, PartialEq, Eq)]
984enum BytesKind {
985    /// A relative file path like `"pdf/fake_memo.pdf"` — read with `std::fs::read(...)`.
986    FilePath,
987    /// Inline text content like `"<!DOCTYPE html>..."` — emit as `b"..."` / `.as_bytes()`.
988    InlineText,
989    /// A base64-encoded blob like `"/9j/4AAQ"` — emit as raw string for now (caller
990    /// receives a string literal; downstream may decode if needed).
991    Base64,
992}
993
994/// Classify a fixture string value that maps to a `bytes` argument.
995///
996/// Rules (in order):
997/// 1. Starts with `<`, `{`, or `[`, or contains whitespace → inline text.
998/// 2. First character is an ASCII letter/digit/underscore AND the value contains
999///    a `/` that is preceded by at least one word character AND the value contains
1000///    a `.` after the last `/` → file path.
1001/// 3. Everything else → base64.
1002fn classify_bytes_value(s: &str) -> BytesKind {
1003    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1004        return BytesKind::InlineText;
1005    }
1006    let first = s.chars().next().unwrap_or('\0');
1007    if first.is_ascii_alphanumeric() || first == '_' {
1008        if let Some(slash_pos) = s.find('/') {
1009            if slash_pos > 0 {
1010                let after_slash = &s[slash_pos + 1..];
1011                if after_slash.contains('.') && !after_slash.is_empty() {
1012                    return BytesKind::FilePath;
1013                }
1014            }
1015        }
1016    }
1017    BytesKind::Base64
1018}
1019
1020/// Render a `json_object` argument: serialize the fixture JSON as a `serde_json::json!` literal
1021/// and deserialize it through serde at runtime. Type inference from the function signature
1022/// determines the concrete type, keeping the generator generic.
1023///
1024/// `owned` — when true the binding is passed by value (no leading `&`); use for `Vec<T>` params.
1025/// `element_type` — when set, emits `Vec<element_type>` annotation to satisfy type inference for
1026///   `&[T]` parameters where `serde_json::from_value` cannot resolve the unsized slice type.
1027fn render_json_object_arg(
1028    name: &str,
1029    value: &serde_json::Value,
1030    optional: bool,
1031    owned: bool,
1032    element_type: Option<&str>,
1033    _module: &str,
1034) -> (Vec<String>, String) {
1035    // Owned params (Vec<T>) are passed by value; ref params (most configs) use &.
1036    let pass_by_ref = !owned;
1037
1038    if value.is_null() && optional {
1039        // Use Default::default() — Rust functions take &T (or T for owned), not Option<T>.
1040        let expr = if pass_by_ref {
1041            format!("&{name}")
1042        } else {
1043            name.to_string()
1044        };
1045        return (vec![format!("let {name} = Default::default();")], expr);
1046    }
1047
1048    // Fixture keys are camelCase; the Rust ConversionOptions type uses snake_case serde.
1049    // Normalize keys before building the json! literal so deserialization succeeds.
1050    let normalized = super::normalize_json_keys_to_snake_case(value);
1051    // Build the json! macro invocation from the fixture object.
1052    let json_literal = json_value_to_macro_literal(&normalized);
1053    let mut lines = Vec::new();
1054    lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
1055
1056    // When an explicit element type is given, annotate with Vec<T> so that
1057    // serde_json::from_value can infer the element type for &[T] parameters (A4 fix).
1058    let deser_expr = if let Some(elem) = element_type {
1059        format!("serde_json::from_value::<Vec<{elem}>>({name}_json).unwrap()")
1060    } else {
1061        format!("serde_json::from_value({name}_json).unwrap()")
1062    };
1063
1064    // A1 fix: always deser as T (never wrap in Some()); optional non-null args target
1065    // &T not &Option<T>. Pass as &T (ref) or T (owned) depending on the `owned` flag.
1066    lines.push(format!("let {name} = {deser_expr};"));
1067    let expr = if pass_by_ref {
1068        format!("&{name}")
1069    } else {
1070        name.to_string()
1071    };
1072    (lines, expr)
1073}
1074
1075/// Convert a `serde_json::Value` into a string suitable for the `serde_json::json!()` macro.
1076fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
1077    match value {
1078        serde_json::Value::Null => "null".to_string(),
1079        serde_json::Value::Bool(b) => format!("{b}"),
1080        serde_json::Value::Number(n) => n.to_string(),
1081        serde_json::Value::String(s) => {
1082            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
1083            format!("\"{escaped}\"")
1084        }
1085        serde_json::Value::Array(arr) => {
1086            let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
1087            format!("[{}]", items.join(", "))
1088        }
1089        serde_json::Value::Object(obj) => {
1090            let entries: Vec<String> = obj
1091                .iter()
1092                .map(|(k, v)| {
1093                    let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
1094                    format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
1095                })
1096                .collect();
1097            format!("{{{}}}", entries.join(", "))
1098        }
1099    }
1100}
1101
1102fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
1103    match value {
1104        serde_json::Value::Null => "None".to_string(),
1105        serde_json::Value::Bool(b) => format!("{b}"),
1106        serde_json::Value::Number(n) => {
1107            if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
1108                if let Some(f) = n.as_f64() {
1109                    return format!("{f}_f64");
1110                }
1111            }
1112            n.to_string()
1113        }
1114        serde_json::Value::String(s) => rust_raw_string(s),
1115        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
1116            let json_str = serde_json::to_string(value).unwrap_or_default();
1117            let literal = rust_raw_string(&json_str);
1118            format!("serde_json::from_str({literal}).unwrap()")
1119        }
1120    }
1121}
1122
1123// ---------------------------------------------------------------------------
1124// TestClientRenderer implementation
1125// ---------------------------------------------------------------------------
1126
1127/// Per-fixture Rust axum-test renderer.
1128///
1129/// Carries the `dep_name` (crate identifier used in generated `use` paths) and
1130/// a reference to the `HttpFixture` so that `render_call` can emit the full
1131/// app + TestServer setup including any middleware (CORS, etc.) that is
1132/// specific to the fixture's handler config.
1133struct RustTestClientRenderer<'a> {
1134    dep_name: &'a str,
1135    http: &'a HttpFixture,
1136}
1137
1138impl<'a> client::TestClientRenderer for RustTestClientRenderer<'a> {
1139    fn language_name(&self) -> &'static str {
1140        "rust"
1141    }
1142
1143    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
1144        let _ = writeln!(out, "#[tokio::test]");
1145        if let Some(reason) = skip_reason {
1146            let _ = writeln!(out, "#[ignore = \"{reason}\"]");
1147        }
1148        let _ = writeln!(out, "async fn test_{fn_name}() {{");
1149        let _ = writeln!(out, "    // {description}");
1150    }
1151
1152    fn render_test_close(&self, out: &mut String) {
1153        let _ = writeln!(out, "}}");
1154    }
1155
1156    fn render_call(&self, out: &mut String, ctx: &CallCtx<'_>) {
1157        let dep_name = self.dep_name;
1158        let http = self.http;
1159        let route = &http.handler.route;
1160        let status = http.expected_response.status_code;
1161
1162        // Serialize expected response body for the handler closure.
1163        let body_str = match &http.expected_response.body {
1164            Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1165            None => String::new(),
1166        };
1167        let body_literal = rust_raw_string(&body_str);
1168
1169        // Serialize request body (if any).
1170        let req_body_str = match &ctx.body {
1171            Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1172            None => String::new(),
1173        };
1174        let has_req_body = !req_body_str.is_empty();
1175
1176        let route_reg = route_registration_for_method(&http.handler.method);
1177        let server_call = server_call_for_method(ctx.method);
1178        let cors_cfg = http.handler.middleware.as_ref().and_then(|m| m.cors.as_ref());
1179
1180        let _ = writeln!(out, "    let expected_body = {body_literal}.to_string();");
1181        let _ = writeln!(out, "    let mut app = {dep_name}::App::new();");
1182
1183        // Route registration.
1184        match &route_reg {
1185            RouteRegistration::Shorthand(method) => {
1186                let _ = writeln!(
1187                    out,
1188                    "    app.route({dep_name}::{method}({route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1189                );
1190            }
1191            RouteRegistration::Explicit(variant) => {
1192                let _ = writeln!(
1193                    out,
1194                    "    app.route({dep_name}::RouteBuilder::new({dep_name}::Method::{variant}, {route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1195                );
1196            }
1197        }
1198        let _ = writeln!(out, "        let body = expected_body.clone();");
1199        let _ = writeln!(out, "        async move {{");
1200        let _ = writeln!(out, "            Ok(axum::http::Response::builder()");
1201        let _ = writeln!(out, "                .status({status}u16)");
1202        let _ = writeln!(out, "                .header(\"content-type\", \"application/json\")");
1203        let _ = writeln!(out, "                .body(axum::body::Body::from(body))");
1204        let _ = writeln!(out, "                .unwrap())");
1205        let _ = writeln!(out, "        }}");
1206        let _ = writeln!(out, "    }}).unwrap();");
1207
1208        let _ = writeln!(out, "    let router = app.into_router().unwrap();");
1209        if let Some(cors) = cors_cfg {
1210            render_cors_layer(out, cors);
1211        }
1212        let _ = writeln!(out, "    let server = axum_test::TestServer::new(router);");
1213
1214        // Build and send the request.
1215        let req_path = ctx.path;
1216        match &server_call {
1217            ServerCall::Shorthand(method) => {
1218                let _ = writeln!(out, "    let {} = server.{method}({req_path:?})", ctx.response_var);
1219            }
1220            ServerCall::AxumMethod(method) => {
1221                let _ = writeln!(
1222                    out,
1223                    "    let {} = server.method(axum::http::Method::{method}, {req_path:?})",
1224                    ctx.response_var
1225                );
1226            }
1227        }
1228
1229        // Add request headers.
1230        for (name, value) in ctx.headers {
1231            let n = rust_raw_string(name);
1232            let v = rust_raw_string(value);
1233            let _ = writeln!(out, "        .add_header({n}, {v})");
1234        }
1235
1236        // Add request body if present.
1237        if has_req_body {
1238            let req_body_literal = rust_raw_string(&req_body_str);
1239            let _ = writeln!(
1240                out,
1241                "        .bytes(bytes::Bytes::copy_from_slice({req_body_literal}.as_bytes()))"
1242            );
1243        }
1244
1245        let _ = writeln!(out, "        .await;");
1246    }
1247
1248    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
1249        let cors_cfg = self.http.handler.middleware.as_ref().and_then(|m| m.cors.as_ref());
1250        // When a CorsLayer is applied and the fixture expects a 2xx status, tower-http
1251        // may return 200 instead of 204 for preflight — accept any 2xx in that case.
1252        if cors_cfg.is_some() && (200..300).contains(&status) {
1253            let _ = writeln!(
1254                out,
1255                "    assert!({response_var}.status_code().is_success(), \"expected CORS success status, got {{}}\", {response_var}.status_code());"
1256            );
1257        } else {
1258            let _ = writeln!(
1259                out,
1260                "    assert_eq!({response_var}.status_code().as_u16(), {status}u16);"
1261            );
1262        }
1263    }
1264
1265    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
1266        match expected {
1267            "<<present>>" => {
1268                let _ = writeln!(
1269                    out,
1270                    "    assert!({response_var}.maybe_header({name:?}).is_some(), \"expected header {name} to be present\");"
1271                );
1272            }
1273            "<<absent>>" => {
1274                let _ = writeln!(
1275                    out,
1276                    "    assert!({response_var}.maybe_header({name:?}).is_none(), \"expected header {name} to be absent\");"
1277                );
1278            }
1279            "<<uuid>>" => {
1280                let _ = writeln!(out, "    {{");
1281                let _ = writeln!(
1282                    out,
1283                    "        let _hval = {response_var}.maybe_header({name:?}).expect(\"expected header {name} to be present\");"
1284                );
1285                let _ = writeln!(
1286                    out,
1287                    "        let _hstr = _hval.to_str().expect(\"header {name} should be valid UTF-8\");"
1288                );
1289                let _ = writeln!(
1290                    out,
1291                    "        assert!(_hstr.len() == 36 && _hstr.chars().filter(|c| *c == '-').count() == 4, \"expected header {name} to be a UUID, got {{_hstr}}\");"
1292                );
1293                let _ = writeln!(out, "    }}");
1294            }
1295            _ => {
1296                let _ = writeln!(
1297                    out,
1298                    "    assert_eq!({response_var}.maybe_header({name:?}).and_then(|v| v.to_str().ok()), Some({expected:?}), \"header {name} mismatch\");"
1299                );
1300            }
1301        }
1302    }
1303
1304    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
1305        let expected_str = serde_json::to_string(expected).unwrap_or_else(|_| "{}".to_string());
1306        let expected_literal = rust_raw_string(&expected_str);
1307        let _ = writeln!(out, "    {{");
1308        let _ = writeln!(out, "        let body: serde_json::Value = {response_var}.json();");
1309        let _ = writeln!(
1310            out,
1311            "        let expected: serde_json::Value = serde_json::from_str({expected_literal}).unwrap();"
1312        );
1313        let _ = writeln!(out, "        assert_eq!(body, expected, \"response body mismatch\");");
1314        let _ = writeln!(out, "    }}");
1315    }
1316
1317    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
1318        let expected_str = serde_json::to_string(expected).unwrap_or_else(|_| "{}".to_string());
1319        let expected_literal = rust_raw_string(&expected_str);
1320        let _ = writeln!(out, "    {{");
1321        let _ = writeln!(out, "        let body: serde_json::Value = {response_var}.json();");
1322        let _ = writeln!(
1323            out,
1324            "        let partial: serde_json::Value = serde_json::from_str({expected_literal}).unwrap();"
1325        );
1326        let _ = writeln!(
1327            out,
1328            "        if let (serde_json::Value::Object(body_map), serde_json::Value::Object(partial_map)) = (&body, &partial) {{"
1329        );
1330        let _ = writeln!(out, "            for (k, v) in partial_map {{");
1331        let _ = writeln!(
1332            out,
1333            "                assert_eq!(body_map.get(k), Some(v), \"partial body field {{k}} mismatch\");"
1334        );
1335        let _ = writeln!(out, "            }}");
1336        let _ = writeln!(out, "        }} else {{");
1337        let _ = writeln!(out, "            assert_eq!(body, partial, \"partial body mismatch\");");
1338        let _ = writeln!(out, "        }}");
1339        let _ = writeln!(out, "    }}");
1340    }
1341
1342    fn render_assert_validation_errors(
1343        &self,
1344        out: &mut String,
1345        response_var: &str,
1346        errors: &[ValidationErrorExpectation],
1347    ) {
1348        let _ = writeln!(out, "    {{");
1349        let _ = writeln!(out, "        let body: serde_json::Value = {response_var}.json();");
1350        let _ = writeln!(
1351            out,
1352            "        let detail = body.get(\"detail\").expect(\"validation error response must have 'detail' field\");"
1353        );
1354        let _ = writeln!(
1355            out,
1356            "        let errs = detail.as_array().expect(\"'detail' must be an array\");"
1357        );
1358        let _ = writeln!(
1359            out,
1360            "        assert_eq!(errs.len(), {}, \"expected {} validation error(s)\");",
1361            errors.len(),
1362            errors.len()
1363        );
1364        for (i, err) in errors.iter().enumerate() {
1365            let loc_json = serde_json::to_string(&err.loc).unwrap_or_else(|_| "[]".to_string());
1366            let loc_literal = rust_raw_string(&loc_json);
1367            let msg_escaped = escape_rust(&err.msg);
1368            let type_escaped = escape_rust(&err.error_type);
1369            let _ = writeln!(out, "        {{");
1370            let _ = writeln!(out, "            let e = &errs[{i}];");
1371            let _ = writeln!(
1372                out,
1373                "            let loc: serde_json::Value = serde_json::from_str({loc_literal}).unwrap();"
1374            );
1375            let _ = writeln!(
1376                out,
1377                "            assert_eq!(e.get(\"loc\"), Some(&loc), \"validation error [{i}] loc mismatch\");"
1378            );
1379            let _ = writeln!(
1380                out,
1381                "            assert_eq!(e.get(\"msg\").and_then(|v| v.as_str()), Some(\"{msg_escaped}\"), \"validation error [{i}] msg mismatch\");"
1382            );
1383            let _ = writeln!(
1384                out,
1385                "            assert_eq!(e.get(\"type\").and_then(|v| v.as_str()), Some(\"{type_escaped}\"), \"validation error [{i}] type mismatch\");"
1386            );
1387            let _ = writeln!(out, "        }}");
1388        }
1389        let _ = writeln!(out, "    }}");
1390    }
1391}
1392
1393// ---------------------------------------------------------------------------
1394// Http integration test helpers
1395// ---------------------------------------------------------------------------
1396
1397/// How to call a method on axum_test::TestServer in generated code.
1398enum ServerCall<'a> {
1399    /// Emit `server.get(path)` / `server.post(path)` etc.
1400    Shorthand(&'a str),
1401    /// Emit `server.method(axum::http::Method::OPTIONS, path)` etc.
1402    AxumMethod(&'a str),
1403}
1404
1405/// How to register a route on the consumer crate's App in generated code.
1406///
1407/// The emitter prefixes every symbol with `{dep_name}` (the consumer's crate
1408/// name), so the generated code calls e.g. `mylib::get(path)` /
1409/// `mylib::RouteBuilder::new(mylib::Method::Options, path)`.
1410enum RouteRegistration<'a> {
1411    /// Emit `{dep_name}::get(path)` / `{dep_name}::post(path)` etc.
1412    Shorthand(&'a str),
1413    /// Emit `{dep_name}::RouteBuilder::new({dep_name}::Method::Options, path)` etc.
1414    Explicit(&'a str),
1415}
1416
1417/// Map a handler HTTP method string to a [`RouteRegistration`] variant.
1418fn route_registration_for_method(method: &str) -> RouteRegistration<'static> {
1419    match method.to_lowercase().as_str() {
1420        "get" => RouteRegistration::Shorthand("get"),
1421        "post" => RouteRegistration::Shorthand("post"),
1422        "put" => RouteRegistration::Shorthand("put"),
1423        "patch" => RouteRegistration::Shorthand("patch"),
1424        "delete" => RouteRegistration::Shorthand("delete"),
1425        "head" => RouteRegistration::Explicit("Head"),
1426        "options" => RouteRegistration::Explicit("Options"),
1427        "trace" => RouteRegistration::Explicit("Trace"),
1428        _ => RouteRegistration::Shorthand("get"),
1429    }
1430}
1431
1432/// Map a request HTTP method string to a [`ServerCall`] variant.
1433fn server_call_for_method(method: &str) -> ServerCall<'static> {
1434    match method.to_uppercase().as_str() {
1435        "GET" => ServerCall::Shorthand("get"),
1436        "POST" => ServerCall::Shorthand("post"),
1437        "PUT" => ServerCall::Shorthand("put"),
1438        "PATCH" => ServerCall::Shorthand("patch"),
1439        "DELETE" => ServerCall::Shorthand("delete"),
1440        "HEAD" => ServerCall::AxumMethod("HEAD"),
1441        "OPTIONS" => ServerCall::AxumMethod("OPTIONS"),
1442        "TRACE" => ServerCall::AxumMethod("TRACE"),
1443        _ => ServerCall::Shorthand("get"),
1444    }
1445}
1446
1447/// Generate a complete integration test function for an http fixture.
1448///
1449/// Handles the static-files special case directly, then delegates to the
1450/// shared [`client::http_call::render_http_test`] driver with a
1451/// [`RustTestClientRenderer`] for the standard axum-test code path.
1452fn render_http_test_function(out: &mut String, fixture: &Fixture, dep_name: &str) {
1453    let http = match &fixture.http {
1454        Some(h) => h,
1455        None => return,
1456    };
1457
1458    // Static-files fixtures bypass the standard App + TestServer setup entirely.
1459    // They are handled here before the shared driver so the driver never sees them.
1460    let static_files_cfgs: Option<&Vec<StaticFilesConfig>> =
1461        http.handler.middleware.as_ref().and_then(|m| m.static_files.as_ref());
1462    if static_files_cfgs.is_some_and(|v| !v.is_empty()) {
1463        let fn_name = sanitize_ident(&fixture.id);
1464        let description = &fixture.description;
1465        let req_path = &http.request.path;
1466        let status = http.expected_response.status_code;
1467        let server_call = server_call_for_method(&http.request.method);
1468        let _ = writeln!(out, "#[tokio::test]");
1469        let _ = writeln!(out, "async fn test_{fn_name}() {{");
1470        let _ = writeln!(out, "    // {description}");
1471        render_static_files_test(out, fixture, static_files_cfgs.unwrap(), &server_call, req_path, status);
1472        return;
1473    }
1474
1475    let renderer = RustTestClientRenderer { dep_name, http };
1476    client::http_call::render_http_test(out, &renderer, fixture);
1477}
1478
1479/// Emit lines that wrap the axum router with a `tower_http::cors::CorsLayer`.
1480///
1481/// The CORS policy is derived from the fixture's `cors` middleware config.
1482/// After this function, `router` is reassigned to the layer-wrapped version.
1483fn render_cors_layer(out: &mut String, cors: &CorsConfig) {
1484    let _ = writeln!(
1485        out,
1486        "    // Apply CorsLayer from tower-http based on fixture CORS config."
1487    );
1488    let _ = writeln!(out, "    use tower_http::cors::CorsLayer;");
1489    let _ = writeln!(out, "    use axum::http::{{HeaderName, HeaderValue, Method}};");
1490    let _ = writeln!(out, "    let cors_layer = CorsLayer::new()");
1491
1492    // allow_origins
1493    if cors.allow_origins.is_empty() {
1494        let _ = writeln!(out, "        .allow_origin(tower_http::cors::Any)");
1495    } else {
1496        let _ = writeln!(out, "        .allow_origin([");
1497        for origin in &cors.allow_origins {
1498            let _ = writeln!(out, "            \"{origin}\".parse::<HeaderValue>().unwrap(),");
1499        }
1500        let _ = writeln!(out, "        ])");
1501    }
1502
1503    // allow_methods
1504    if cors.allow_methods.is_empty() {
1505        let _ = writeln!(out, "        .allow_methods(tower_http::cors::Any)");
1506    } else {
1507        let methods: Vec<String> = cors
1508            .allow_methods
1509            .iter()
1510            .map(|m| format!("Method::{}", m.to_uppercase()))
1511            .collect();
1512        let _ = writeln!(out, "        .allow_methods([{}])", methods.join(", "));
1513    }
1514
1515    // allow_headers
1516    if cors.allow_headers.is_empty() {
1517        let _ = writeln!(out, "        .allow_headers(tower_http::cors::Any)");
1518    } else {
1519        let headers: Vec<String> = cors
1520            .allow_headers
1521            .iter()
1522            .map(|h| {
1523                let lower = h.to_lowercase();
1524                match lower.as_str() {
1525                    "content-type" => "axum::http::header::CONTENT_TYPE".to_string(),
1526                    "authorization" => "axum::http::header::AUTHORIZATION".to_string(),
1527                    "accept" => "axum::http::header::ACCEPT".to_string(),
1528                    _ => format!("HeaderName::from_static(\"{lower}\")"),
1529                }
1530            })
1531            .collect();
1532        let _ = writeln!(out, "        .allow_headers([{}])", headers.join(", "));
1533    }
1534
1535    // max_age
1536    if let Some(secs) = cors.max_age {
1537        let _ = writeln!(out, "        .max_age(std::time::Duration::from_secs({secs}));");
1538    } else {
1539        let _ = writeln!(out, "        ;");
1540    }
1541
1542    let _ = writeln!(out, "    let router = router.layer(cors_layer);");
1543}
1544
1545/// Emit lines for a static-files integration test.
1546///
1547/// Writes fixture files to a temporary directory and serves them via
1548/// `tower_http::services::ServeDir`, bypassing the consumer's App entirely.
1549fn render_static_files_test(
1550    out: &mut String,
1551    fixture: &Fixture,
1552    cfgs: &[StaticFilesConfig],
1553    server_call: &ServerCall<'_>,
1554    req_path: &str,
1555    status: u16,
1556) {
1557    let http = fixture.http.as_ref().unwrap();
1558
1559    let _ = writeln!(out, "    use tower_http::services::ServeDir;");
1560    let _ = writeln!(out, "    use axum::Router;");
1561    let _ = writeln!(out, "    let tmp_dir = tempfile::tempdir().expect(\"tmp dir\");");
1562
1563    // Build the router by nesting a ServeDir for each config entry.
1564    let _ = writeln!(out, "    let mut router = Router::new();");
1565    for cfg in cfgs {
1566        for file in &cfg.files {
1567            let file_path = file.path.replace('\\', "/");
1568            let content = rust_raw_string(&file.content);
1569            if file_path.contains('/') {
1570                let parent: String = file_path.rsplitn(2, '/').last().unwrap_or("").to_string();
1571                let _ = writeln!(
1572                    out,
1573                    "    std::fs::create_dir_all(tmp_dir.path().join(\"{parent}\")).unwrap();"
1574                );
1575            }
1576            let _ = writeln!(
1577                out,
1578                "    std::fs::write(tmp_dir.path().join(\"{file_path}\"), {content}).unwrap();"
1579            );
1580        }
1581        let prefix = &cfg.route_prefix;
1582        let serve_dir_expr = if cfg.index_file {
1583            "ServeDir::new(tmp_dir.path()).append_index_html_on_directories(true)".to_string()
1584        } else {
1585            "ServeDir::new(tmp_dir.path())".to_string()
1586        };
1587        let _ = writeln!(out, "    router = router.nest_service({prefix:?}, {serve_dir_expr});");
1588    }
1589
1590    let _ = writeln!(out, "    let server = axum_test::TestServer::new(router);");
1591
1592    // Build and send the request.
1593    match server_call {
1594        ServerCall::Shorthand(method) => {
1595            let _ = writeln!(out, "    let response = server.{method}({req_path:?})");
1596        }
1597        ServerCall::AxumMethod(method) => {
1598            let _ = writeln!(
1599                out,
1600                "    let response = server.method(axum::http::Method::{method}, {req_path:?})"
1601            );
1602        }
1603    }
1604
1605    // Add request headers.
1606    for (name, value) in &http.request.headers {
1607        let n = rust_raw_string(name);
1608        let v = rust_raw_string(value);
1609        let _ = writeln!(out, "        .add_header({n}, {v})");
1610    }
1611
1612    let _ = writeln!(out, "        .await;");
1613    let _ = writeln!(out, "    assert_eq!(response.status_code().as_u16(), {status}u16);");
1614    let _ = writeln!(out, "}}");
1615}
1616
1617// ---------------------------------------------------------------------------
1618// Mock server helpers
1619// ---------------------------------------------------------------------------
1620
1621/// Emit mock server setup lines into a test function body.
1622///
1623/// Builds `MockRoute` objects from the fixture's `mock_response` and starts
1624/// the server.  The resulting `mock_server` variable is in scope for the rest
1625/// of the test function.
1626fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
1627    let mock = match fixture.mock_response.as_ref() {
1628        Some(m) => m,
1629        None => return,
1630    };
1631
1632    // Resolve the HTTP path and method from the call config.
1633    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1634    let path = call_config.path.as_deref().unwrap_or("/");
1635    let method = call_config.method.as_deref().unwrap_or("POST");
1636
1637    let status = mock.status;
1638
1639    // Render headers map as a Vec<(String, String)> literal for stable iteration order.
1640    let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
1641    header_entries.sort_by(|a, b| a.0.cmp(b.0));
1642    let render_headers = |out: &mut String| {
1643        let _ = writeln!(out, "        headers: vec![");
1644        for (name, value) in &header_entries {
1645            let n = rust_raw_string(name);
1646            let v = rust_raw_string(value);
1647            let _ = writeln!(out, "            ({n}.to_string(), {v}.to_string()),");
1648        }
1649        let _ = writeln!(out, "        ],");
1650    };
1651
1652    if let Some(chunks) = &mock.stream_chunks {
1653        // Streaming SSE response.
1654        let _ = writeln!(out, "    let mock_route = MockRoute {{");
1655        let _ = writeln!(out, "        path: \"{path}\",");
1656        let _ = writeln!(out, "        method: \"{method}\",");
1657        let _ = writeln!(out, "        status: {status},");
1658        let _ = writeln!(out, "        body: String::new(),");
1659        let _ = writeln!(out, "        stream_chunks: vec![");
1660        for chunk in chunks {
1661            let chunk_str = match chunk {
1662                serde_json::Value::String(s) => rust_raw_string(s),
1663                other => {
1664                    let s = serde_json::to_string(other).unwrap_or_default();
1665                    rust_raw_string(&s)
1666                }
1667            };
1668            let _ = writeln!(out, "            {chunk_str}.to_string(),");
1669        }
1670        let _ = writeln!(out, "        ],");
1671        render_headers(out);
1672        let _ = writeln!(out, "    }};");
1673    } else {
1674        // Non-streaming JSON response.
1675        let body_str = match &mock.body {
1676            Some(b) => {
1677                let s = serde_json::to_string(b).unwrap_or_default();
1678                rust_raw_string(&s)
1679            }
1680            None => rust_raw_string("{}"),
1681        };
1682        let _ = writeln!(out, "    let mock_route = MockRoute {{");
1683        let _ = writeln!(out, "        path: \"{path}\",");
1684        let _ = writeln!(out, "        method: \"{method}\",");
1685        let _ = writeln!(out, "        status: {status},");
1686        let _ = writeln!(out, "        body: {body_str}.to_string(),");
1687        let _ = writeln!(out, "        stream_chunks: vec![],");
1688        render_headers(out);
1689        let _ = writeln!(out, "    }};");
1690    }
1691
1692    let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
1693}
1694
1695/// Generate the complete `mock_server.rs` module source.
1696pub fn render_mock_server_module() -> String {
1697    // This is parameterized Axum mock server code identical in structure to
1698    // liter-llm's mock_server.rs but without any project-specific imports.
1699    hash::header(CommentStyle::DoubleSlash)
1700        + r#"//
1701// Minimal axum-based mock HTTP server for e2e tests.
1702
1703use std::net::SocketAddr;
1704use std::sync::Arc;
1705
1706use axum::Router;
1707use axum::body::Body;
1708use axum::extract::State;
1709use axum::http::{Request, StatusCode};
1710use axum::response::{IntoResponse, Response};
1711use tokio::net::TcpListener;
1712
1713/// A single mock route: match by path + method, return a configured response.
1714#[derive(Clone, Debug)]
1715pub struct MockRoute {
1716    /// URL path to match, e.g. `"/v1/chat/completions"`.
1717    pub path: &'static str,
1718    /// HTTP method to match, e.g. `"POST"` or `"GET"`.
1719    pub method: &'static str,
1720    /// HTTP status code to return.
1721    pub status: u16,
1722    /// Response body JSON string (used when `stream_chunks` is empty).
1723    pub body: String,
1724    /// Ordered SSE data payloads for streaming responses.
1725    /// Each entry becomes `data: <chunk>\n\n` in the response.
1726    /// A final `data: [DONE]\n\n` is always appended.
1727    pub stream_chunks: Vec<String>,
1728    /// Response headers to apply (name, value) pairs.
1729    /// Multiple entries with the same name produce multiple header lines.
1730    pub headers: Vec<(String, String)>,
1731}
1732
1733struct ServerState {
1734    routes: Vec<MockRoute>,
1735}
1736
1737pub struct MockServer {
1738    /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
1739    pub url: String,
1740    handle: tokio::task::JoinHandle<()>,
1741}
1742
1743impl MockServer {
1744    /// Start a mock server with the given routes.  Binds to a random port on
1745    /// localhost and returns immediately once the server is listening.
1746    pub async fn start(routes: Vec<MockRoute>) -> Self {
1747        let state = Arc::new(ServerState { routes });
1748
1749        let app = Router::new().fallback(handle_request).with_state(state);
1750
1751        let listener = TcpListener::bind("127.0.0.1:0")
1752            .await
1753            .expect("Failed to bind mock server port");
1754        let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
1755        let url = format!("http://{addr}");
1756
1757        let handle = tokio::spawn(async move {
1758            axum::serve(listener, app).await.expect("Mock server failed");
1759        });
1760
1761        MockServer { url, handle }
1762    }
1763
1764    /// Stop the mock server.
1765    pub fn shutdown(self) {
1766        self.handle.abort();
1767    }
1768}
1769
1770impl Drop for MockServer {
1771    fn drop(&mut self) {
1772        self.handle.abort();
1773    }
1774}
1775
1776async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
1777    let path = req.uri().path().to_owned();
1778    let method = req.method().as_str().to_uppercase();
1779
1780    for route in &state.routes {
1781        if route.path == path && route.method.to_uppercase() == method {
1782            let status =
1783                StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1784
1785            if !route.stream_chunks.is_empty() {
1786                // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
1787                let mut sse = String::new();
1788                for chunk in &route.stream_chunks {
1789                    sse.push_str("data: ");
1790                    sse.push_str(chunk);
1791                    sse.push_str("\n\n");
1792                }
1793                sse.push_str("data: [DONE]\n\n");
1794
1795                let mut builder = Response::builder()
1796                    .status(status)
1797                    .header("content-type", "text/event-stream")
1798                    .header("cache-control", "no-cache");
1799                for (name, value) in &route.headers {
1800                    builder = builder.header(name, value);
1801                }
1802                return builder.body(Body::from(sse)).unwrap().into_response();
1803            }
1804
1805            let mut builder =
1806                Response::builder().status(status).header("content-type", "application/json");
1807            for (name, value) in &route.headers {
1808                builder = builder.header(name, value);
1809            }
1810            return builder.body(Body::from(route.body.clone())).unwrap().into_response();
1811        }
1812    }
1813
1814    // No matching route → 404.
1815    Response::builder()
1816        .status(StatusCode::NOT_FOUND)
1817        .body(Body::from(format!("No mock route for {method} {path}")))
1818        .unwrap()
1819        .into_response()
1820}
1821"#
1822}
1823
1824/// Generate the `src/main.rs` for the standalone mock server binary.
1825///
1826/// The binary:
1827/// - Reads all `*.json` fixture files from a fixtures directory (default `../../fixtures`).
1828/// - For each fixture that has a `mock_response` field, registers a route at
1829///   `/fixtures/{fixture_id}` returning the configured status/body/SSE chunks.
1830/// - Binds to `127.0.0.1:0` (random port), prints `MOCK_SERVER_URL=http://...`
1831///   to stdout, then waits until stdin is closed for clean teardown.
1832///
1833/// This binary is intended for cross-language e2e suites (WASM, Node) that
1834/// spawn it as a child process and read the URL from its stdout.
1835pub fn render_mock_server_binary() -> String {
1836    hash::header(CommentStyle::DoubleSlash)
1837        + r#"//
1838// Standalone mock HTTP server binary for cross-language e2e tests.
1839// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
1840//
1841// Usage: mock-server [fixtures-dir]
1842//   fixtures-dir defaults to "../../fixtures"
1843//
1844// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
1845// then blocks until stdin is closed (parent process exit triggers cleanup).
1846
1847use std::collections::HashMap;
1848use std::io::{self, BufRead};
1849use std::net::SocketAddr;
1850use std::path::Path;
1851use std::sync::Arc;
1852
1853use axum::Router;
1854use axum::body::Body;
1855use axum::extract::State;
1856use axum::http::{Request, StatusCode};
1857use axum::response::{IntoResponse, Response};
1858use serde::Deserialize;
1859use tokio::net::TcpListener;
1860
1861// ---------------------------------------------------------------------------
1862// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1863// Supports both schemas:
1864//   mock_response: { status, body, stream_chunks }
1865//   http.expected_response: { status_code, body, headers }
1866// ---------------------------------------------------------------------------
1867
1868#[derive(Debug, Deserialize)]
1869struct MockResponse {
1870    status: u16,
1871    #[serde(default)]
1872    body: Option<serde_json::Value>,
1873    #[serde(default)]
1874    stream_chunks: Option<Vec<serde_json::Value>>,
1875    #[serde(default)]
1876    headers: HashMap<String, String>,
1877}
1878
1879#[derive(Debug, Deserialize)]
1880struct HttpExpectedResponse {
1881    status_code: u16,
1882    #[serde(default)]
1883    body: Option<serde_json::Value>,
1884    #[serde(default)]
1885    headers: HashMap<String, String>,
1886}
1887
1888#[derive(Debug, Deserialize)]
1889struct HttpFixture {
1890    expected_response: HttpExpectedResponse,
1891}
1892
1893#[derive(Debug, Deserialize)]
1894struct Fixture {
1895    id: String,
1896    #[serde(default)]
1897    mock_response: Option<MockResponse>,
1898    #[serde(default)]
1899    http: Option<HttpFixture>,
1900}
1901
1902impl Fixture {
1903    /// Bridge both schemas into a unified MockResponse.
1904    fn as_mock_response(&self) -> Option<MockResponse> {
1905        if let Some(mock) = &self.mock_response {
1906            return Some(MockResponse {
1907                status: mock.status,
1908                body: mock.body.clone(),
1909                stream_chunks: mock.stream_chunks.clone(),
1910                headers: mock.headers.clone(),
1911            });
1912        }
1913        if let Some(http) = &self.http {
1914            return Some(MockResponse {
1915                status: http.expected_response.status_code,
1916                body: http.expected_response.body.clone(),
1917                stream_chunks: None,
1918                headers: http.expected_response.headers.clone(),
1919            });
1920        }
1921        None
1922    }
1923}
1924
1925// ---------------------------------------------------------------------------
1926// Route table
1927// ---------------------------------------------------------------------------
1928
1929#[derive(Clone, Debug)]
1930struct MockRoute {
1931    status: u16,
1932    body: String,
1933    stream_chunks: Vec<String>,
1934    headers: Vec<(String, String)>,
1935}
1936
1937type RouteTable = Arc<HashMap<String, MockRoute>>;
1938
1939// ---------------------------------------------------------------------------
1940// Axum handler
1941// ---------------------------------------------------------------------------
1942
1943async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1944    let path = req.uri().path().to_owned();
1945
1946    // Try exact match first
1947    if let Some(route) = routes.get(&path) {
1948        return serve_route(route);
1949    }
1950
1951    // Try prefix match: find a route that is a prefix of the request path
1952    // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1953    for (route_path, route) in routes.iter() {
1954        if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1955            return serve_route(route);
1956        }
1957    }
1958
1959    Response::builder()
1960        .status(StatusCode::NOT_FOUND)
1961        .body(Body::from(format!("No mock route for {path}")))
1962        .unwrap()
1963        .into_response()
1964}
1965
1966fn serve_route(route: &MockRoute) -> Response {
1967    let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1968
1969    if !route.stream_chunks.is_empty() {
1970        let mut sse = String::new();
1971        for chunk in &route.stream_chunks {
1972            sse.push_str("data: ");
1973            sse.push_str(chunk);
1974            sse.push_str("\n\n");
1975        }
1976        sse.push_str("data: [DONE]\n\n");
1977
1978        let mut builder = Response::builder()
1979            .status(status)
1980            .header("content-type", "text/event-stream")
1981            .header("cache-control", "no-cache");
1982        for (name, value) in &route.headers {
1983            builder = builder.header(name, value);
1984        }
1985        return builder.body(Body::from(sse)).unwrap().into_response();
1986    }
1987
1988    // Only set the default content-type if the fixture does not override it.
1989    // Use application/json when the body looks like JSON (starts with { or [),
1990    // otherwise fall back to text/plain to avoid clients failing JSON-decode.
1991    let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
1992    let mut builder = Response::builder().status(status);
1993    if !has_content_type {
1994        let trimmed = route.body.trim_start();
1995        let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
1996            "application/json"
1997        } else {
1998            "text/plain"
1999        };
2000        builder = builder.header("content-type", default_ct);
2001    }
2002    for (name, value) in &route.headers {
2003        // Skip content-encoding headers — the mock server returns uncompressed bodies.
2004        // Sending a content-encoding without actually encoding the body would cause
2005        // clients to fail decompression.
2006        if name.to_lowercase() == "content-encoding" {
2007            continue;
2008        }
2009        // The <<absent>> sentinel means this header must NOT be present in the
2010        // real server response — do not emit it from the mock server either.
2011        if value == "<<absent>>" {
2012            continue;
2013        }
2014        // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
2015        // assert the header value matches the UUID pattern.
2016        if value == "<<uuid>>" {
2017            let uuid = format!(
2018                "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
2019                rand_u32(),
2020                rand_u16(),
2021                rand_u16() & 0x0fff,
2022                (rand_u16() & 0x3fff) | 0x8000,
2023                rand_u48(),
2024            );
2025            builder = builder.header(name, uuid);
2026            continue;
2027        }
2028        builder = builder.header(name, value);
2029    }
2030    builder.body(Body::from(route.body.clone())).unwrap().into_response()
2031}
2032
2033/// Generate a pseudo-random u32 using the current time nanoseconds.
2034fn rand_u32() -> u32 {
2035    use std::time::{SystemTime, UNIX_EPOCH};
2036    let ns = SystemTime::now()
2037        .duration_since(UNIX_EPOCH)
2038        .map(|d| d.subsec_nanos())
2039        .unwrap_or(0);
2040    ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
2041}
2042
2043fn rand_u16() -> u16 {
2044    (rand_u32() & 0xffff) as u16
2045}
2046
2047fn rand_u48() -> u64 {
2048    ((rand_u32() as u64) << 16) | (rand_u16() as u64)
2049}
2050
2051// ---------------------------------------------------------------------------
2052// Fixture loading
2053// ---------------------------------------------------------------------------
2054
2055fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
2056    let mut routes = HashMap::new();
2057    load_routes_recursive(fixtures_dir, &mut routes);
2058    routes
2059}
2060
2061fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
2062    let entries = match std::fs::read_dir(dir) {
2063        Ok(e) => e,
2064        Err(err) => {
2065            eprintln!("warning: cannot read directory {}: {err}", dir.display());
2066            return;
2067        }
2068    };
2069
2070    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
2071    paths.sort();
2072
2073    for path in paths {
2074        if path.is_dir() {
2075            load_routes_recursive(&path, routes);
2076        } else if path.extension().is_some_and(|ext| ext == "json") {
2077            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
2078            if filename == "schema.json" || filename.starts_with('_') {
2079                continue;
2080            }
2081            let content = match std::fs::read_to_string(&path) {
2082                Ok(c) => c,
2083                Err(err) => {
2084                    eprintln!("warning: cannot read {}: {err}", path.display());
2085                    continue;
2086                }
2087            };
2088            let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
2089                match serde_json::from_str(&content) {
2090                    Ok(v) => v,
2091                    Err(err) => {
2092                        eprintln!("warning: cannot parse {}: {err}", path.display());
2093                        continue;
2094                    }
2095                }
2096            } else {
2097                match serde_json::from_str::<Fixture>(&content) {
2098                    Ok(f) => vec![f],
2099                    Err(err) => {
2100                        eprintln!("warning: cannot parse {}: {err}", path.display());
2101                        continue;
2102                    }
2103                }
2104            };
2105
2106            for fixture in fixtures {
2107                if let Some(mock) = fixture.as_mock_response() {
2108                    let route_path = format!("/fixtures/{}", fixture.id);
2109                    let body = mock
2110                        .body
2111                        .as_ref()
2112                        .map(|b| match b {
2113                            // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
2114                            // fixtures. Return the raw value so clients receive the string itself,
2115                            // not its JSON-encoded form with extra surrounding quotes.
2116                            serde_json::Value::String(s) => s.clone(),
2117                            other => serde_json::to_string(other).unwrap_or_default(),
2118                        })
2119                        .unwrap_or_default();
2120                    let stream_chunks = mock
2121                        .stream_chunks
2122                        .unwrap_or_default()
2123                        .into_iter()
2124                        .map(|c| match c {
2125                            serde_json::Value::String(s) => s,
2126                            other => serde_json::to_string(&other).unwrap_or_default(),
2127                        })
2128                        .collect();
2129                    let mut headers: Vec<(String, String)> =
2130                        mock.headers.into_iter().collect();
2131                    headers.sort_by(|a, b| a.0.cmp(&b.0));
2132                    routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
2133                }
2134            }
2135        }
2136    }
2137}
2138
2139// ---------------------------------------------------------------------------
2140// Entry point
2141// ---------------------------------------------------------------------------
2142
2143#[tokio::main]
2144async fn main() {
2145    let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
2146    let fixtures_dir = Path::new(&fixtures_dir_arg);
2147
2148    let routes = load_routes(fixtures_dir);
2149    eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
2150
2151    let route_table: RouteTable = Arc::new(routes);
2152    let app = Router::new().fallback(handle_request).with_state(route_table);
2153
2154    let listener = TcpListener::bind("127.0.0.1:0")
2155        .await
2156        .expect("mock-server: failed to bind port");
2157    let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
2158
2159    // Print the URL so the parent process can read it.
2160    println!("MOCK_SERVER_URL=http://{addr}");
2161    // Flush stdout explicitly so the parent does not block waiting.
2162    use std::io::Write;
2163    std::io::stdout().flush().expect("mock-server: failed to flush stdout");
2164
2165    // Spawn the server in the background.
2166    tokio::spawn(async move {
2167        axum::serve(listener, app).await.expect("mock-server: server error");
2168    });
2169
2170    // Block until stdin is closed — the parent process controls lifetime.
2171    let stdin = io::stdin();
2172    let mut lines = stdin.lock().lines();
2173    while lines.next().is_some() {}
2174}
2175"#
2176}
2177
2178// ---------------------------------------------------------------------------
2179// Assertion rendering
2180// ---------------------------------------------------------------------------
2181
2182#[allow(clippy::too_many_arguments)]
2183fn render_assertion(
2184    out: &mut String,
2185    assertion: &Assertion,
2186    result_var: &str,
2187    module: &str,
2188    dep_name: &str,
2189    is_error_context: bool,
2190    unwrapped_fields: &[(String, String)], // (fixture_field, local_var)
2191    field_resolver: &FieldResolver,
2192    result_is_tree: bool,
2193    result_is_simple: bool,
2194    result_is_vec: bool,
2195    result_is_option: bool,
2196) {
2197    // Vec<T> result: iterate per-element so each assertion checks every element.
2198    // Field-path assertions become `for r in &{result} { <assert using r> }`.
2199    // Length-style assertions on the Vec itself (no field path) operate on the
2200    // Vec directly.
2201    let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
2202    if result_is_vec && has_field && !is_error_context {
2203        let _ = writeln!(out, "    for r in &{result_var} {{");
2204        render_assertion(
2205            out,
2206            assertion,
2207            "r",
2208            module,
2209            dep_name,
2210            is_error_context,
2211            unwrapped_fields,
2212            field_resolver,
2213            result_is_tree,
2214            result_is_simple,
2215            false, // already inside loop
2216            result_is_option,
2217        );
2218        let _ = writeln!(out, "    }}");
2219        return;
2220    }
2221    // Option<T> result: map `is_empty`/`not_empty` to `is_none()`/`is_some()`,
2222    // and unwrap the inner value before any other assertion runs.
2223    if result_is_option && !is_error_context {
2224        let assertion_type = assertion.assertion_type.as_str();
2225        if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
2226            let check = if assertion_type == "is_empty" {
2227                "is_none"
2228            } else {
2229                "is_some"
2230            };
2231            let _ = writeln!(
2232                out,
2233                "    assert!({result_var}.{check}(), \"expected Option to be {check}\");"
2234            );
2235            return;
2236        }
2237        // For any other assertion shape, unwrap the Option and recurse with a
2238        // bare reference variable so the rest of the renderer treats the inner
2239        // value as the result.
2240        let _ = writeln!(
2241            out,
2242            "    let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
2243        );
2244        render_assertion(
2245            out,
2246            assertion,
2247            "r",
2248            module,
2249            dep_name,
2250            is_error_context,
2251            unwrapped_fields,
2252            field_resolver,
2253            result_is_tree,
2254            result_is_simple,
2255            result_is_vec,
2256            false, // already unwrapped
2257        );
2258        return;
2259    }
2260    let _ = dep_name;
2261    // Handle synthetic fields like chunks_have_content (derived assertions).
2262    // These are computed expressions, not real struct fields — intercept before
2263    // the is_valid_for_result check so they are never treated as field accesses.
2264    if let Some(f) = &assertion.field {
2265        match f.as_str() {
2266            "chunks_have_content" => {
2267                match assertion.assertion_type.as_str() {
2268                    "is_true" => {
2269                        let _ = writeln!(
2270                            out,
2271                            "    assert!({result_var}.chunks.as_ref().is_some_and(|chunks| !chunks.is_empty() && chunks.iter().all(|c| !c.content.is_empty())), \"expected all chunks to have content\");"
2272                        );
2273                    }
2274                    "is_false" => {
2275                        let _ = writeln!(
2276                            out,
2277                            "    assert!({result_var}.chunks.as_ref().is_none() || {result_var}.chunks.as_ref().unwrap().iter().any(|c| c.content.is_empty()), \"expected some chunks to be empty\");"
2278                        );
2279                    }
2280                    _ => {
2281                        let _ = writeln!(
2282                            out,
2283                            "    // unsupported assertion type on synthetic field chunks_have_content"
2284                        );
2285                    }
2286                }
2287                return;
2288            }
2289            "chunks_have_embeddings" => {
2290                match assertion.assertion_type.as_str() {
2291                    "is_true" => {
2292                        let _ = writeln!(
2293                            out,
2294                            "    assert!({result_var}.chunks.as_ref().is_some_and(|c| c.iter().all(|ch| ch.embedding.as_ref().is_some_and(|e| !e.is_empty()))), \"expected all chunks to have embeddings\");"
2295                        );
2296                    }
2297                    "is_false" => {
2298                        let _ = writeln!(
2299                            out,
2300                            "    assert!({result_var}.chunks.as_ref().is_none_or(|c| c.iter().any(|ch| ch.embedding.as_ref().is_none_or(|e| e.is_empty()))), \"expected some chunks to lack embeddings\");"
2301                        );
2302                    }
2303                    _ => {
2304                        let _ = writeln!(
2305                            out,
2306                            "    // unsupported assertion type on synthetic field chunks_have_embeddings"
2307                        );
2308                    }
2309                }
2310                return;
2311            }
2312            // ---- EmbedResponse virtual fields ----
2313            // embed_texts returns Vec<Vec<f32>> in Rust (no wrapper struct).
2314            // result_var is the embedding matrix — use it directly.
2315            "embeddings" => {
2316                // "embeddings" as a field in count_equals/count_min means the outer list.
2317                // embed_texts returns Vec<Vec<f32>> directly; result_var IS the embedding matrix.
2318                let embed_list = result_var.to_string();
2319                match assertion.assertion_type.as_str() {
2320                    "count_equals" => {
2321                        if let Some(val) = &assertion.value {
2322                            if let Some(n) = val.as_u64() {
2323                                let _ = writeln!(
2324                                    out,
2325                                    "    assert_eq!({embed_list}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {embed_list}.len());"
2326                                );
2327                            }
2328                        }
2329                    }
2330                    "count_min" => {
2331                        if let Some(val) = &assertion.value {
2332                            if let Some(n) = val.as_u64() {
2333                                if n <= 1 {
2334                                    let _ =
2335                                        writeln!(out, "    assert!(!{embed_list}.is_empty(), \"expected >= {n}\");");
2336                                } else {
2337                                    let _ = writeln!(
2338                                        out,
2339                                        "    assert!({embed_list}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {embed_list}.len());"
2340                                    );
2341                                }
2342                            }
2343                        }
2344                    }
2345                    "not_empty" => {
2346                        let _ = writeln!(
2347                            out,
2348                            "    assert!(!{embed_list}.is_empty(), \"expected non-empty embeddings\");"
2349                        );
2350                    }
2351                    "is_empty" => {
2352                        let _ = writeln!(
2353                            out,
2354                            "    assert!({embed_list}.is_empty(), \"expected empty embeddings\");"
2355                        );
2356                    }
2357                    _ => {
2358                        let _ = writeln!(
2359                            out,
2360                            "    // skipped: unsupported assertion type on synthetic field 'embeddings'"
2361                        );
2362                    }
2363                }
2364                return;
2365            }
2366            "embedding_dimensions" => {
2367                let embed_list = result_var;
2368                let expr = format!("{embed_list}.first().map_or(0, |e| e.len())");
2369                match assertion.assertion_type.as_str() {
2370                    "equals" => {
2371                        if let Some(val) = &assertion.value {
2372                            let lit = numeric_literal(val);
2373                            let _ = writeln!(
2374                                out,
2375                                "    assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2376                            );
2377                        }
2378                    }
2379                    "greater_than" => {
2380                        if let Some(val) = &assertion.value {
2381                            let lit = numeric_literal(val);
2382                            let _ = writeln!(out, "    assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2383                        }
2384                    }
2385                    _ => {
2386                        let _ = writeln!(
2387                            out,
2388                            "    // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
2389                        );
2390                    }
2391                }
2392                return;
2393            }
2394            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2395                let embed_list = result_var;
2396                let pred = match f.as_str() {
2397                    "embeddings_valid" => {
2398                        format!("{embed_list}.iter().all(|e| !e.is_empty())")
2399                    }
2400                    "embeddings_finite" => {
2401                        format!("{embed_list}.iter().all(|e| e.iter().all(|v| v.is_finite()))")
2402                    }
2403                    "embeddings_non_zero" => {
2404                        format!("{embed_list}.iter().all(|e| e.iter().any(|v| *v != 0.0_f32))")
2405                    }
2406                    "embeddings_normalized" => {
2407                        format!(
2408                            "{embed_list}.iter().all(|e| {{ let n: f64 = e.iter().map(|v| f64::from(*v) * f64::from(*v)).sum(); (n - 1.0_f64).abs() < 1e-3 }})"
2409                        )
2410                    }
2411                    _ => unreachable!(),
2412                };
2413                match assertion.assertion_type.as_str() {
2414                    "is_true" => {
2415                        let _ = writeln!(out, "    assert!({pred}, \"expected true\");");
2416                    }
2417                    "is_false" => {
2418                        let _ = writeln!(out, "    assert!(!({pred}), \"expected false\");");
2419                    }
2420                    _ => {
2421                        let _ = writeln!(
2422                            out,
2423                            "    // skipped: unsupported assertion type on synthetic field '{f}'"
2424                        );
2425                    }
2426                }
2427                return;
2428            }
2429            // ---- keywords / keywords_count ----
2430            // ExtractionResult has `extracted_keywords: Option<Vec<Keyword>>`.
2431            // Translate fixture virtual fields to that real accessor.
2432            "keywords" => {
2433                let accessor = format!("{result_var}.extracted_keywords");
2434                match assertion.assertion_type.as_str() {
2435                    "not_empty" => {
2436                        let _ = writeln!(
2437                            out,
2438                            "    assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected keywords to be present and non-empty\");"
2439                        );
2440                    }
2441                    "is_empty" => {
2442                        let _ = writeln!(
2443                            out,
2444                            "    assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected keywords to be empty or absent\");"
2445                        );
2446                    }
2447                    "count_min" => {
2448                        if let Some(val) = &assertion.value {
2449                            if let Some(n) = val.as_u64() {
2450                                if n <= 1 {
2451                                    let _ = writeln!(
2452                                        out,
2453                                        "    assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2454                                    );
2455                                } else {
2456                                    let _ = writeln!(
2457                                        out,
2458                                        "    assert!({accessor}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} keywords\");"
2459                                    );
2460                                }
2461                            }
2462                        }
2463                    }
2464                    "count_equals" => {
2465                        if let Some(val) = &assertion.value {
2466                            if let Some(n) = val.as_u64() {
2467                                let _ = writeln!(
2468                                    out,
2469                                    "    assert!({accessor}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} keywords\");"
2470                                );
2471                            }
2472                        }
2473                    }
2474                    _ => {
2475                        let _ = writeln!(
2476                            out,
2477                            "    // skipped: unsupported assertion type on synthetic field 'keywords'"
2478                        );
2479                    }
2480                }
2481                return;
2482            }
2483            "keywords_count" => {
2484                let expr = format!("{result_var}.extracted_keywords.as_ref().map_or(0, |v| v.len())");
2485                match assertion.assertion_type.as_str() {
2486                    "equals" => {
2487                        if let Some(val) = &assertion.value {
2488                            let lit = numeric_literal(val);
2489                            let _ = writeln!(
2490                                out,
2491                                "    assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2492                            );
2493                        }
2494                    }
2495                    "less_than_or_equal" => {
2496                        if let Some(val) = &assertion.value {
2497                            let lit = numeric_literal(val);
2498                            let _ = writeln!(out, "    assert!({expr} <= {lit} as usize, \"expected <= {lit}\");");
2499                        }
2500                    }
2501                    "greater_than_or_equal" => {
2502                        if let Some(val) = &assertion.value {
2503                            let lit = numeric_literal(val);
2504                            let _ = writeln!(out, "    assert!({expr} >= {lit} as usize, \"expected >= {lit}\");");
2505                        }
2506                    }
2507                    "greater_than" => {
2508                        if let Some(val) = &assertion.value {
2509                            let lit = numeric_literal(val);
2510                            let _ = writeln!(out, "    assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2511                        }
2512                    }
2513                    "less_than" => {
2514                        if let Some(val) = &assertion.value {
2515                            let lit = numeric_literal(val);
2516                            let _ = writeln!(out, "    assert!({expr} < {lit} as usize, \"expected < {lit}\");");
2517                        }
2518                    }
2519                    _ => {
2520                        let _ = writeln!(
2521                            out,
2522                            "    // skipped: unsupported assertion type on synthetic field 'keywords_count'"
2523                        );
2524                    }
2525                }
2526                return;
2527            }
2528            _ => {}
2529        }
2530    }
2531
2532    // Skip assertions on fields that don't exist on the result type.
2533    if let Some(f) = &assertion.field {
2534        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2535            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
2536            return;
2537        }
2538    }
2539
2540    // Determine field access expression:
2541    // 1. If the field was unwrapped to a local var, use that local var name.
2542    // 2. When result_is_simple, the function returns a plain type (String etc.) — use result_var.
2543    // 3. When the field path is exactly the result var name (sentinel: `field: "result"`),
2544    //    refer to the result variable directly to avoid emitting `result.result`.
2545    // 4. When the result is a Tree, map pseudo-field names to correct Rust expressions.
2546    // 5. Otherwise, use the field resolver to generate the accessor.
2547    let field_access = match &assertion.field {
2548        Some(f) if !f.is_empty() => {
2549            if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
2550                local_var.clone()
2551            } else if result_is_simple {
2552                // Plain return type (String, Vec<T>, etc.) has no struct fields.
2553                // Use the result variable directly so assertions operate on the value itself.
2554                result_var.to_string()
2555            } else if f == result_var {
2556                // Sentinel: fixture uses `field: "result"` (or matches the result variable name)
2557                // to refer to the whole return value, not a struct field named "result".
2558                result_var.to_string()
2559            } else if result_is_tree {
2560                // Tree is an opaque type — its "fields" are accessed via root_node() or
2561                // free functions. Map known pseudo-field names to correct Rust expressions.
2562                tree_field_access_expr(f, result_var, module)
2563            } else {
2564                field_resolver.accessor(f, "rust", result_var)
2565            }
2566        }
2567        _ => result_var.to_string(),
2568    };
2569
2570    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
2571    let is_unwrapped = assertion
2572        .field
2573        .as_ref()
2574        .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
2575
2576    match assertion.assertion_type.as_str() {
2577        "error" => {
2578            let _ = writeln!(out, "    assert!({result_var}.is_err(), \"expected call to fail\");");
2579            if let Some(serde_json::Value::String(msg)) = &assertion.value {
2580                let escaped = escape_rust(msg);
2581                let _ = writeln!(
2582                    out,
2583                    "    assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
2584                );
2585            }
2586        }
2587        "not_error" => {
2588            // Handled at call site; nothing extra needed here.
2589        }
2590        "equals" => {
2591            if let Some(val) = &assertion.value {
2592                let expected = value_to_rust_string(val);
2593                if is_error_context {
2594                    return;
2595                }
2596                // For string equality, trim trailing whitespace to handle trailing newlines
2597                // from the converter.
2598                if val.is_string() {
2599                    // When the field is Optional<String> and was NOT pre-unwrapped to a local
2600                    // var (e.g. inside a result_is_vec iteration where the call-site unwrap
2601                    // pass is skipped), emit `.as_deref().unwrap_or("").trim()` so the
2602                    // expression is `&str` rather than `Option<String>`.
2603                    let is_opt_str_not_unwrapped = assertion.field.as_ref().is_some_and(|f| {
2604                        let resolved = field_resolver.resolve(f);
2605                        let is_opt = field_resolver.is_optional(resolved);
2606                        let is_arr = field_resolver.is_array(resolved);
2607                        is_opt && !is_arr && !is_unwrapped
2608                    });
2609                    let field_expr = if is_opt_str_not_unwrapped {
2610                        format!("{field_access}.as_deref().unwrap_or(\"\").trim()")
2611                    } else {
2612                        format!("{field_access}.trim()")
2613                    };
2614                    let _ = writeln!(
2615                        out,
2616                        "    assert_eq!({field_expr}, {expected}, \"equals assertion failed\");"
2617                    );
2618                } else if val.is_boolean() {
2619                    // Use assert!/assert!(!...) for booleans — clippy prefers this over assert_eq!(_, true/false).
2620                    if val.as_bool() == Some(true) {
2621                        let _ = writeln!(out, "    assert!({field_access}, \"equals assertion failed\");");
2622                    } else {
2623                        let _ = writeln!(out, "    assert!(!{field_access}, \"equals assertion failed\");");
2624                    }
2625                } else {
2626                    // Wrap expected value in Some() for optional fields.
2627                    let is_opt = assertion.field.as_ref().is_some_and(|f| {
2628                        let resolved = field_resolver.resolve(f);
2629                        field_resolver.is_optional(resolved)
2630                    });
2631                    if is_opt
2632                        && !unwrapped_fields
2633                            .iter()
2634                            .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
2635                    {
2636                        let _ = writeln!(
2637                            out,
2638                            "    assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
2639                        );
2640                    } else {
2641                        let _ = writeln!(
2642                            out,
2643                            "    assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
2644                        );
2645                    }
2646                }
2647            }
2648        }
2649        "contains" => {
2650            if let Some(val) = &assertion.value {
2651                let expected = value_to_rust_string(val);
2652                let line = format!(
2653                    "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2654                );
2655                let _ = writeln!(out, "{line}");
2656            }
2657        }
2658        "contains_all" => {
2659            if let Some(values) = &assertion.values {
2660                for val in values {
2661                    let expected = value_to_rust_string(val);
2662                    let line = format!(
2663                        "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2664                    );
2665                    let _ = writeln!(out, "{line}");
2666                }
2667            }
2668        }
2669        "not_contains" => {
2670            if let Some(val) = &assertion.value {
2671                let expected = value_to_rust_string(val);
2672                let line = format!(
2673                    "    assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
2674                );
2675                let _ = writeln!(out, "{line}");
2676            }
2677        }
2678        "not_empty" => {
2679            if let Some(f) = &assertion.field {
2680                let resolved = field_resolver.resolve(f);
2681                let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2682                let is_arr = field_resolver.is_array(resolved);
2683                if is_opt && is_arr {
2684                    // Option<Vec<T>>: must be Some AND inner non-empty.
2685                    let accessor = field_resolver.accessor(f, "rust", result_var);
2686                    let _ = writeln!(
2687                        out,
2688                        "    assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected {f} to be present and non-empty\");"
2689                    );
2690                } else if is_opt {
2691                    // Non-collection optional field (e.g., Option<Struct>): use is_some().
2692                    let accessor = field_resolver.accessor(f, "rust", result_var);
2693                    let _ = writeln!(
2694                        out,
2695                        "    assert!({accessor}.is_some(), \"expected {f} to be present\");"
2696                    );
2697                } else {
2698                    let _ = writeln!(
2699                        out,
2700                        "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2701                    );
2702                }
2703            } else if result_is_option {
2704                // Bare result is Option<T>: not_empty == is_some().
2705                let _ = writeln!(
2706                    out,
2707                    "    assert!({field_access}.is_some(), \"expected non-empty value\");"
2708                );
2709            } else {
2710                // Bare result is a struct/string/collection — non-empty via is_empty().
2711                let _ = writeln!(
2712                    out,
2713                    "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2714                );
2715            }
2716        }
2717        "is_empty" => {
2718            if let Some(f) = &assertion.field {
2719                let resolved = field_resolver.resolve(f);
2720                let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2721                let is_arr = field_resolver.is_array(resolved);
2722                if is_opt && is_arr {
2723                    // Option<Vec<T>>: empty means None or empty vec.
2724                    let accessor = field_resolver.accessor(f, "rust", result_var);
2725                    let _ = writeln!(
2726                        out,
2727                        "    assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected {f} to be empty or absent\");"
2728                    );
2729                } else if is_opt {
2730                    let accessor = field_resolver.accessor(f, "rust", result_var);
2731                    let _ = writeln!(out, "    assert!({accessor}.is_none(), \"expected {f} to be absent\");");
2732                } else {
2733                    let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
2734                }
2735            } else {
2736                let _ = writeln!(out, "    assert!({field_access}.is_none(), \"expected empty value\");");
2737            }
2738        }
2739        "contains_any" => {
2740            if let Some(values) = &assertion.values {
2741                let checks: Vec<String> = values
2742                    .iter()
2743                    .map(|v| {
2744                        let expected = value_to_rust_string(v);
2745                        format!("{field_access}.contains({expected})")
2746                    })
2747                    .collect();
2748                let joined = checks.join(" || ");
2749                let _ = writeln!(
2750                    out,
2751                    "    assert!({joined}, \"expected to contain at least one of the specified values\");"
2752                );
2753            }
2754        }
2755        "greater_than" => {
2756            if let Some(val) = &assertion.value {
2757                // Skip comparisons with negative values against unsigned types (.len() etc.)
2758                if val.as_f64().is_some_and(|n| n < 0.0) {
2759                    let _ = writeln!(
2760                        out,
2761                        "    // skipped: greater_than with negative value is always true for unsigned types"
2762                    );
2763                } else if val.as_u64() == Some(0) {
2764                    // Clippy prefers !is_empty() over len() > 0
2765                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2766                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected > 0\");");
2767                } else {
2768                    let lit = numeric_literal(val);
2769                    let _ = writeln!(out, "    assert!({field_access} > {lit}, \"expected > {lit}\");");
2770                }
2771            }
2772        }
2773        "less_than" => {
2774            if let Some(val) = &assertion.value {
2775                let lit = numeric_literal(val);
2776                let _ = writeln!(out, "    assert!({field_access} < {lit}, \"expected < {lit}\");");
2777            }
2778        }
2779        "greater_than_or_equal" => {
2780            if let Some(val) = &assertion.value {
2781                let lit = numeric_literal(val);
2782                // Check whether this field is optional but not an array — e.g. Option<usize>.
2783                // Directly comparing Option<usize> >= N is a type error; wrap with unwrap_or(0).
2784                let is_opt_numeric = assertion.field.as_ref().is_some_and(|f| {
2785                    let resolved = field_resolver.resolve(f);
2786                    let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2787                    let is_arr = field_resolver.is_array(resolved);
2788                    is_opt && !is_arr
2789                });
2790                if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
2791                    // Clippy prefers !is_empty() over len() >= 1 for collections.
2792                    // Only apply when the expression is already a `.len()` call so we
2793                    // don't mistakenly call `.is_empty()` on numeric (usize) fields.
2794                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2795                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= 1\");");
2796                } else if is_opt_numeric {
2797                    // Option<usize> / Option<u64>: unwrap to 0 before comparing.
2798                    let _ = writeln!(
2799                        out,
2800                        "    assert!({field_access}.unwrap_or(0) >= {lit}, \"expected >= {lit}\");"
2801                    );
2802                } else {
2803                    let _ = writeln!(out, "    assert!({field_access} >= {lit}, \"expected >= {lit}\");");
2804                }
2805            }
2806        }
2807        "less_than_or_equal" => {
2808            if let Some(val) = &assertion.value {
2809                let lit = numeric_literal(val);
2810                let _ = writeln!(out, "    assert!({field_access} <= {lit}, \"expected <= {lit}\");");
2811            }
2812        }
2813        "starts_with" => {
2814            if let Some(val) = &assertion.value {
2815                let expected = value_to_rust_string(val);
2816                let _ = writeln!(
2817                    out,
2818                    "    assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
2819                );
2820            }
2821        }
2822        "ends_with" => {
2823            if let Some(val) = &assertion.value {
2824                let expected = value_to_rust_string(val);
2825                let _ = writeln!(
2826                    out,
2827                    "    assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
2828                );
2829            }
2830        }
2831        "min_length" => {
2832            if let Some(val) = &assertion.value {
2833                if let Some(n) = val.as_u64() {
2834                    let _ = writeln!(
2835                        out,
2836                        "    assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
2837                    );
2838                }
2839            }
2840        }
2841        "max_length" => {
2842            if let Some(val) = &assertion.value {
2843                if let Some(n) = val.as_u64() {
2844                    let _ = writeln!(
2845                        out,
2846                        "    assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
2847                    );
2848                }
2849            }
2850        }
2851        "count_min" => {
2852            if let Some(val) = &assertion.value {
2853                if let Some(n) = val.as_u64() {
2854                    let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2855                        let resolved = field_resolver.resolve(f);
2856                        let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2857                        let is_arr = field_resolver.is_array(resolved);
2858                        is_opt && is_arr
2859                    });
2860                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2861                    if opt_arr_field {
2862                        // Option<Vec<T>>: must be Some AND inner len >= n.
2863                        if n <= 1 {
2864                            let _ = writeln!(
2865                                out,
2866                                "    assert!({base}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2867                            );
2868                        } else {
2869                            let _ = writeln!(
2870                                out,
2871                                "    assert!({base}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} elements\");"
2872                            );
2873                        }
2874                    } else if n <= 1 {
2875                        let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= {n}\");");
2876                    } else {
2877                        let _ = writeln!(
2878                            out,
2879                            "    assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
2880                        );
2881                    }
2882                }
2883            }
2884        }
2885        "count_equals" => {
2886            if let Some(val) = &assertion.value {
2887                if let Some(n) = val.as_u64() {
2888                    let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2889                        let resolved = field_resolver.resolve(f);
2890                        let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2891                        let is_arr = field_resolver.is_array(resolved);
2892                        is_opt && is_arr
2893                    });
2894                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2895                    if opt_arr_field {
2896                        let _ = writeln!(
2897                            out,
2898                            "    assert!({base}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} elements\");"
2899                        );
2900                    } else {
2901                        let _ = writeln!(
2902                            out,
2903                            "    assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
2904                        );
2905                    }
2906                }
2907            }
2908        }
2909        "is_true" => {
2910            let _ = writeln!(out, "    assert!({field_access}, \"expected true\");");
2911        }
2912        "is_false" => {
2913            let _ = writeln!(out, "    assert!(!{field_access}, \"expected false\");");
2914        }
2915        "method_result" => {
2916            if let Some(method_name) = &assertion.method {
2917                // Build the call expression. When the result is a tree-sitter Tree (an opaque
2918                // type), methods like `root_child_count` do not exist on `Tree` directly —
2919                // they are free functions in the crate or are accessed via `root_node()`.
2920                let call_expr = if result_is_tree {
2921                    build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
2922                } else if let Some(args) = &assertion.args {
2923                    let arg_lit = json_to_rust_literal(args, "");
2924                    format!("{field_access}.{method_name}({arg_lit})")
2925                } else {
2926                    format!("{field_access}.{method_name}()")
2927                };
2928
2929                // Determine whether the call expression returns a numeric type so we can
2930                // choose the right comparison strategy for `greater_than_or_equal`.
2931                let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
2932
2933                let check = assertion.check.as_deref().unwrap_or("is_true");
2934                match check {
2935                    "equals" => {
2936                        if let Some(val) = &assertion.value {
2937                            if val.is_boolean() {
2938                                if val.as_bool() == Some(true) {
2939                                    let _ = writeln!(
2940                                        out,
2941                                        "    assert!({call_expr}, \"method_result equals assertion failed\");"
2942                                    );
2943                                } else {
2944                                    let _ = writeln!(
2945                                        out,
2946                                        "    assert!(!{call_expr}, \"method_result equals assertion failed\");"
2947                                    );
2948                                }
2949                            } else {
2950                                let expected = value_to_rust_string(val);
2951                                let _ = writeln!(
2952                                    out,
2953                                    "    assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
2954                                );
2955                            }
2956                        }
2957                    }
2958                    "is_true" => {
2959                        let _ = writeln!(
2960                            out,
2961                            "    assert!({call_expr}, \"method_result is_true assertion failed\");"
2962                        );
2963                    }
2964                    "is_false" => {
2965                        let _ = writeln!(
2966                            out,
2967                            "    assert!(!{call_expr}, \"method_result is_false assertion failed\");"
2968                        );
2969                    }
2970                    "greater_than_or_equal" => {
2971                        if let Some(val) = &assertion.value {
2972                            let lit = numeric_literal(val);
2973                            if returns_numeric {
2974                                // Numeric return (e.g., child_count()) — always use >= comparison.
2975                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2976                            } else if val.as_u64() == Some(1) {
2977                                // Clippy prefers !is_empty() over len() >= 1 for collections.
2978                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
2979                            } else {
2980                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2981                            }
2982                        }
2983                    }
2984                    "count_min" => {
2985                        if let Some(val) = &assertion.value {
2986                            let n = val.as_u64().unwrap_or(0);
2987                            if n <= 1 {
2988                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
2989                            } else {
2990                                let _ = writeln!(
2991                                    out,
2992                                    "    assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
2993                                );
2994                            }
2995                        }
2996                    }
2997                    "is_error" => {
2998                        // For is_error we need the raw Result without .unwrap().
2999                        let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
3000                        let _ = writeln!(
3001                            out,
3002                            "    assert!({raw_call}.is_err(), \"expected method to return error\");"
3003                        );
3004                    }
3005                    "contains" => {
3006                        if let Some(val) = &assertion.value {
3007                            let expected = value_to_rust_string(val);
3008                            let _ = writeln!(
3009                                out,
3010                                "    assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
3011                            );
3012                        }
3013                    }
3014                    "not_empty" => {
3015                        let _ = writeln!(
3016                            out,
3017                            "    assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
3018                        );
3019                    }
3020                    "is_empty" => {
3021                        let _ = writeln!(out, "    assert!({call_expr}.is_empty(), \"expected empty result\");");
3022                    }
3023                    other_check => {
3024                        panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
3025                    }
3026                }
3027            } else {
3028                panic!("Rust e2e generator: method_result assertion missing 'method' field");
3029            }
3030        }
3031        other => {
3032            panic!("Rust e2e generator: unsupported assertion type: {other}");
3033        }
3034    }
3035}
3036
3037/// Translate a fixture pseudo-field name on a `tree_sitter::Tree` into the
3038/// correct Rust accessor expression.
3039///
3040/// When an assertion uses `field: "root_child_count"` on a tree result, the
3041/// field resolver would naively emit `tree.root_child_count` — which is invalid
3042/// because `Tree` is an opaque type with no such field.  This function maps the
3043/// pseudo-field to the correct Rust expression instead.
3044fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
3045    match field {
3046        "root_child_count" => format!("{result_var}.root_node().child_count()"),
3047        "root_node_type" => format!("{result_var}.root_node().kind()"),
3048        "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
3049        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
3050        "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
3051        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
3052        // Unknown pseudo-field: fall back to direct field access (will likely fail to compile,
3053        // but gives the developer a useful error pointing to the fixture).
3054        other => format!("{result_var}.{other}"),
3055    }
3056}
3057
3058/// Build a Rust call expression for a logical "method" on a `tree_sitter::Tree`.
3059///
3060/// `Tree` is an opaque type — it does not expose methods like `root_child_count`.
3061/// Instead, these are either free functions in the crate or are accessed via
3062/// `tree.root_node().<method>()`. This function translates the fixture-level
3063/// method name into the correct Rust expression.
3064fn build_tree_call_expr(
3065    field_access: &str,
3066    method_name: &str,
3067    args: Option<&serde_json::Value>,
3068    module: &str,
3069) -> String {
3070    match method_name {
3071        "root_child_count" => format!("{field_access}.root_node().child_count()"),
3072        "root_node_type" => format!("{field_access}.root_node().kind()"),
3073        "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
3074        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
3075        "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
3076        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
3077        "contains_node_type" => {
3078            let node_type = args
3079                .and_then(|a| a.get("node_type"))
3080                .and_then(|v| v.as_str())
3081                .unwrap_or("");
3082            format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
3083        }
3084        "find_nodes_by_type" => {
3085            let node_type = args
3086                .and_then(|a| a.get("node_type"))
3087                .and_then(|v| v.as_str())
3088                .unwrap_or("");
3089            format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
3090        }
3091        "run_query" => {
3092            let query_source = args
3093                .and_then(|a| a.get("query_source"))
3094                .and_then(|v| v.as_str())
3095                .unwrap_or("");
3096            let language = args
3097                .and_then(|a| a.get("language"))
3098                .and_then(|v| v.as_str())
3099                .unwrap_or("");
3100            // Use a raw string for the query to avoid escaping issues.
3101            // run_query returns Result — unwrap it for assertion access.
3102            format!(
3103                "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
3104            )
3105        }
3106        // Fallback: try as a plain method call.
3107        _ => {
3108            if let Some(args) = args {
3109                let arg_lit = json_to_rust_literal(args, "");
3110                format!("{field_access}.{method_name}({arg_lit})")
3111            } else {
3112                format!("{field_access}.{method_name}()")
3113            }
3114        }
3115    }
3116}
3117
3118/// Returns `true` when the tree method name produces a numeric result (usize/u64),
3119/// meaning `>= N` comparisons should use direct numeric comparison rather than
3120/// `.is_empty()` (which only works for collections).
3121fn is_tree_numeric_method(method_name: &str) -> bool {
3122    matches!(
3123        method_name,
3124        "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
3125    )
3126}
3127
3128/// Convert a JSON numeric value to a Rust literal suitable for comparisons.
3129///
3130/// Whole numbers (no fractional part) are emitted as bare integer literals so
3131/// they are compatible with `usize`, `u64`, etc. (e.g., `.len()` results).
3132/// Numbers with a fractional component get the `_f64` suffix.
3133fn numeric_literal(value: &serde_json::Value) -> String {
3134    if let Some(n) = value.as_f64() {
3135        if n.fract() == 0.0 {
3136            // Whole number — emit without a type suffix so Rust can infer the
3137            // correct integer type from context (usize, u64, i64, …).
3138            return format!("{}", n as i64);
3139        }
3140        return format!("{n}_f64");
3141    }
3142    // Fallback: use the raw JSON representation.
3143    value.to_string()
3144}
3145
3146fn value_to_rust_string(value: &serde_json::Value) -> String {
3147    match value {
3148        serde_json::Value::String(s) => rust_raw_string(s),
3149        serde_json::Value::Bool(b) => format!("{b}"),
3150        serde_json::Value::Number(n) => n.to_string(),
3151        other => {
3152            let s = other.to_string();
3153            format!("\"{s}\"")
3154        }
3155    }
3156}
3157
3158// ---------------------------------------------------------------------------
3159// Visitor generation
3160// ---------------------------------------------------------------------------
3161
3162/// Resolve the visitor trait name based on module.
3163fn resolve_visitor_trait(module: &str) -> String {
3164    // For html_to_markdown modules, use HtmlVisitor
3165    if module.contains("html_to_markdown") {
3166        "HtmlVisitor".to_string()
3167    } else {
3168        // Default fallback for other modules
3169        "Visitor".to_string()
3170    }
3171}
3172
3173/// Resolve the module path that contains the visitor trait.
3174///
3175/// When the trait lives in a sub-module (e.g. `html_to_markdown_rs::visitor`)
3176/// rather than the crate root, this returns the full sub-module path so the
3177/// caller can emit a separate `use` statement for the trait import.
3178fn resolve_visitor_trait_module(module: &str) -> String {
3179    if module.contains("html_to_markdown") {
3180        format!("{module}::visitor")
3181    } else {
3182        module.to_string()
3183    }
3184}
3185
3186/// Parameter descriptor for a single visitor method parameter.
3187///
3188/// The `name` field is the logical name (without `_` suppression prefix — that
3189/// is added at render time for unused params).
3190/// The `ty` field is the Rust type string.
3191/// The `unwrap_option` flag indicates this is `Option<&str>` and the body
3192/// needs a `let {name} = {name}.unwrap_or_default();` binding to expose a
3193/// plain `&str` for template interpolation.
3194struct VisitorParam {
3195    name: &'static str,
3196    ty: &'static str,
3197    /// When true, the declared param type is `Option<&str>` and the bare name
3198    /// starts with `_` to suppress unused-variable warnings.  For
3199    /// `CustomTemplate` actions that reference this param, the prefix is
3200    /// stripped and an unwrap binding is emitted.
3201    unwrap_option: bool,
3202}
3203
3204impl VisitorParam {
3205    const fn new(name: &'static str, ty: &'static str) -> Self {
3206        Self {
3207            name,
3208            ty,
3209            unwrap_option: false,
3210        }
3211    }
3212
3213    /// An `Option<&str>` parameter whose plain string value may be needed by
3214    /// `CustomTemplate` actions.  The name stored here is the bare identifier
3215    /// (no `_` prefix); the prefix is added at render time when the param is
3216    /// not referenced.
3217    const fn option_str(name: &'static str) -> Self {
3218        Self {
3219            name,
3220            ty: "Option<&str>",
3221            unwrap_option: true,
3222        }
3223    }
3224
3225    /// An `Option<&str>` parameter that is never referenced by templates.
3226    /// The name stored here already includes the `_` prefix.
3227    const fn option_str_unused(name: &'static str) -> Self {
3228        Self {
3229            name,
3230            ty: "Option<&str>",
3231            unwrap_option: false,
3232        }
3233    }
3234}
3235
3236/// Returns the typed parameter list for a visitor method, matching the
3237/// `HtmlVisitor` trait signatures exactly.
3238///
3239/// The first `&NodeContext` parameter is always included; the returned slice
3240/// covers only the additional parameters (after ctx).
3241fn visitor_method_extra_params(method_name: &str) -> Vec<VisitorParam> {
3242    match method_name {
3243        "visit_link" => vec![
3244            VisitorParam::new("href", "&str"),
3245            VisitorParam::new("text", "&str"),
3246            VisitorParam::option_str_unused("_title"),
3247        ],
3248        "visit_image" => vec![
3249            VisitorParam::new("src", "&str"),
3250            VisitorParam::new("alt", "&str"),
3251            VisitorParam::option_str_unused("_title"),
3252        ],
3253        "visit_heading" => vec![
3254            VisitorParam::new("level", "u32"),
3255            VisitorParam::new("text", "&str"),
3256            VisitorParam::option_str_unused("_id"),
3257        ],
3258        "visit_code_block" => vec![
3259            VisitorParam::option_str_unused("_lang"),
3260            VisitorParam::new("code", "&str"),
3261        ],
3262        "visit_code_inline"
3263        | "visit_strong"
3264        | "visit_emphasis"
3265        | "visit_strikethrough"
3266        | "visit_underline"
3267        | "visit_subscript"
3268        | "visit_superscript"
3269        | "visit_mark"
3270        | "visit_button"
3271        | "visit_summary"
3272        | "visit_figcaption"
3273        | "visit_definition_term"
3274        | "visit_definition_description"
3275        | "visit_text" => {
3276            vec![VisitorParam::new("text", "&str")]
3277        }
3278        "visit_list_item" => vec![
3279            VisitorParam::new("ordered", "bool"),
3280            VisitorParam::new("marker", "&str"),
3281            VisitorParam::new("text", "&str"),
3282        ],
3283        "visit_blockquote" => vec![
3284            VisitorParam::new("content", "&str"),
3285            VisitorParam::new("depth", "usize"),
3286        ],
3287        "visit_table_row" => vec![
3288            VisitorParam::new("cells", "&[String]"),
3289            VisitorParam::new("is_header", "bool"),
3290        ],
3291        "visit_custom_element" => vec![VisitorParam::new("tag_name", "&str"), VisitorParam::new("html", "&str")],
3292        "visit_form" => vec![
3293            VisitorParam::option_str_unused("_action"),
3294            VisitorParam::option_str_unused("_method"),
3295        ],
3296        "visit_input" => vec![
3297            VisitorParam::new("input_type", "&str"),
3298            VisitorParam::option_str_unused("_name"),
3299            VisitorParam::option_str_unused("_value"),
3300        ],
3301        "visit_audio" | "visit_video" | "visit_iframe" => {
3302            vec![VisitorParam::option_str("src")]
3303        }
3304        "visit_details" => vec![VisitorParam::new("open", "bool")],
3305        "visit_list_start" => vec![VisitorParam::new("ordered", "bool")],
3306        "visit_list_end" => vec![
3307            VisitorParam::new("ordered", "bool"),
3308            VisitorParam::new("output", "&str"),
3309        ],
3310        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
3311            vec![VisitorParam::new("output", "&str")]
3312        }
3313        _ => vec![],
3314    }
3315}
3316
3317/// Emit a Rust visitor method for a callback action.
3318///
3319/// Parameter names are suppressed with a `_` prefix unless the action is
3320/// `CustomTemplate`, in which case params referenced by the template are
3321/// exposed as plain identifiers so the `format!()` call can capture them.
3322/// `Option<&str>` parameters referenced in a template are additionally
3323/// unwrapped via `let name = name.unwrap_or_default();`.
3324fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
3325    let extra = visitor_method_extra_params(method_name);
3326
3327    // Determine which param names the template references (if any).
3328    let referenced: Vec<&str> = if let CallbackAction::CustomTemplate { template } = action {
3329        extra
3330            .iter()
3331            .filter(|p| {
3332                let bare = p.name.trim_start_matches('_');
3333                template.contains(&format!("{{{bare}}}"))
3334            })
3335            .map(|p| p.name.trim_start_matches('_'))
3336            .collect()
3337    } else {
3338        Vec::new()
3339    };
3340
3341    // Build the parameter string, suppressing names that aren't referenced.
3342    let mut param_parts = vec!["_: &NodeContext".to_string()];
3343    for p in &extra {
3344        let bare = p.name.trim_start_matches('_');
3345        let is_used = referenced.contains(&bare);
3346        let param_name = if is_used {
3347            bare.to_string()
3348        } else if p.name.starts_with('_') {
3349            p.name.to_string()
3350        } else {
3351            format!("_{bare}")
3352        };
3353        param_parts.push(format!("{param_name}: {}", p.ty));
3354    }
3355    let params = param_parts.join(", ");
3356
3357    let _ = writeln!(out, "        fn {method_name}(&mut self, {params}) -> VisitResult {{");
3358
3359    // For template params that are Option<&str>, emit an unwrap binding so
3360    // the format! string can use a plain &str.
3361    if let CallbackAction::CustomTemplate { .. } = action {
3362        for p in &extra {
3363            let bare = p.name.trim_start_matches('_');
3364            if p.unwrap_option && referenced.contains(&bare) {
3365                let _ = writeln!(out, "            let {bare} = {bare}.unwrap_or_default();");
3366            }
3367        }
3368    }
3369
3370    match action {
3371        CallbackAction::Skip => {
3372            let _ = writeln!(out, "            VisitResult::Skip");
3373        }
3374        CallbackAction::Continue => {
3375            let _ = writeln!(out, "            VisitResult::Continue");
3376        }
3377        CallbackAction::PreserveHtml => {
3378            let _ = writeln!(out, "            VisitResult::PreserveHtml");
3379        }
3380        CallbackAction::Custom { output } => {
3381            let escaped = escape_rust(output);
3382            let _ = writeln!(out, "            VisitResult::Custom(\"{escaped}\".to_string())");
3383        }
3384        CallbackAction::CustomTemplate { template } => {
3385            let escaped = escape_rust(template);
3386            let _ = writeln!(out, "            VisitResult::Custom(format!(\"{escaped}\"))");
3387        }
3388    }
3389    let _ = writeln!(out, "        }}");
3390}