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::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{escape_rust, rust_raw_string, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions as tv;
15use anyhow::Result;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19/// Rust e2e test code generator.
20pub struct RustE2eCodegen;
21
22impl super::E2eCodegen for RustE2eCodegen {
23    fn generate(
24        &self,
25        groups: &[FixtureGroup],
26        e2e_config: &E2eConfig,
27        alef_config: &AlefConfig,
28    ) -> Result<Vec<GeneratedFile>> {
29        let mut files = Vec::new();
30        let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
31
32        // Resolve crate name and path from config.
33        let crate_name = resolve_crate_name(e2e_config, alef_config);
34        let crate_path = resolve_crate_path(e2e_config, &crate_name);
35        let dep_name = crate_name.replace('-', "_");
36
37        // Cargo.toml
38        // Check if any call config (default or named) uses json_object/handle args (needs serde_json dep).
39        let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
40        let needs_serde_json = all_call_configs
41            .flat_map(|c| c.args.iter())
42            .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
43
44        // Check if any fixture in any group requires a mock HTTP server.
45        // This includes both liter-llm mock_response fixtures and spikard http fixtures.
46        let needs_mock_server = groups
47            .iter()
48            .flat_map(|g| g.fixtures.iter())
49            .any(|f| !is_skipped(f, "rust") && f.mock_response.is_some());
50
51        // Check if any fixture uses the http integration test pattern (spikard http fixtures).
52        let needs_http_tests = groups
53            .iter()
54            .flat_map(|g| g.fixtures.iter())
55            .any(|f| !is_skipped(f, "rust") && f.http.is_some());
56
57        // Tokio is needed when any test is async (mock server, http tests, or async call config).
58        let any_async_call = std::iter::once(&e2e_config.call)
59            .chain(e2e_config.calls.values())
60            .any(|c| c.r#async);
61        let needs_tokio = needs_mock_server || needs_http_tests || any_async_call;
62
63        let crate_version = resolve_crate_version(e2e_config);
64        files.push(GeneratedFile {
65            path: output_base.join("Cargo.toml"),
66            content: render_cargo_toml(
67                &crate_name,
68                &dep_name,
69                &crate_path,
70                needs_serde_json,
71                needs_mock_server,
72                needs_http_tests,
73                needs_tokio,
74                e2e_config.dep_mode,
75                crate_version.as_deref(),
76                &alef_config.crate_config.features,
77            ),
78            generated_header: true,
79        });
80
81        // Generate mock_server.rs when at least one fixture uses mock_response.
82        if needs_mock_server {
83            files.push(GeneratedFile {
84                path: output_base.join("tests").join("mock_server.rs"),
85                content: render_mock_server_module(),
86                generated_header: true,
87            });
88        }
89        // Always generate standalone mock-server binary for cross-language e2e suites
90        // when any fixture has http data (serves fixture responses for non-Rust tests).
91        if needs_mock_server || needs_http_tests {
92            files.push(GeneratedFile {
93                path: output_base.join("src").join("main.rs"),
94                content: render_mock_server_binary(),
95                generated_header: true,
96            });
97        }
98
99        // Per-category test files.
100        for group in groups {
101            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
102
103            if fixtures.is_empty() {
104                continue;
105            }
106
107            let filename = format!("{}_test.rs", sanitize_filename(&group.category));
108            let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
109
110            files.push(GeneratedFile {
111                path: output_base.join("tests").join(filename),
112                content,
113                generated_header: true,
114            });
115        }
116
117        Ok(files)
118    }
119
120    fn language_name(&self) -> &'static str {
121        "rust"
122    }
123}
124
125// ---------------------------------------------------------------------------
126// Config resolution helpers
127// ---------------------------------------------------------------------------
128
129fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
130    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
131    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
132    // import identifier, not the Cargo package name.
133    alef_config.crate_config.name.clone()
134}
135
136fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
137    e2e_config
138        .resolve_package("rust")
139        .and_then(|p| p.path.clone())
140        .unwrap_or_else(|| format!("../../crates/{crate_name}"))
141}
142
143fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
144    e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
145}
146
147fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
148    call_config
149        .overrides
150        .get("rust")
151        .and_then(|o| o.function.clone())
152        .unwrap_or_else(|| call_config.function.clone())
153}
154
155fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
156    resolve_module_for_call(&e2e_config.call, dep_name)
157}
158
159fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
160    // For Rust, the module name is the crate identifier (underscores).
161    // Priority: override.crate_name > override.module > dep_name
162    let overrides = call_config.overrides.get("rust");
163    overrides
164        .and_then(|o| o.crate_name.clone())
165        .or_else(|| overrides.and_then(|o| o.module.clone()))
166        .unwrap_or_else(|| dep_name.to_string())
167}
168
169fn is_skipped(fixture: &Fixture, language: &str) -> bool {
170    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
171}
172
173// ---------------------------------------------------------------------------
174// Rendering
175// ---------------------------------------------------------------------------
176
177#[allow(clippy::too_many_arguments)]
178pub fn render_cargo_toml(
179    crate_name: &str,
180    dep_name: &str,
181    crate_path: &str,
182    needs_serde_json: bool,
183    needs_mock_server: bool,
184    needs_http_tests: bool,
185    needs_tokio: bool,
186    dep_mode: crate::config::DependencyMode,
187    version: Option<&str>,
188    features: &[String],
189) -> String {
190    let e2e_name = format!("{dep_name}-e2e-rust");
191    // Use only the features explicitly configured in alef.toml.
192    // Do NOT auto-add "serde" — the target crate may not have that feature.
193    // serde_json is added as a separate dependency when needed.
194    let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
195    let features_str = if effective_features.is_empty() {
196        String::new()
197    } else {
198        format!(", default-features = false, features = {:?}", effective_features)
199    };
200    let dep_spec = match dep_mode {
201        crate::config::DependencyMode::Registry => {
202            let ver = version.unwrap_or("0.1.0");
203            if crate_name != dep_name {
204                format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
205            } else if effective_features.is_empty() {
206                format!("{dep_name} = \"{ver}\"")
207            } else {
208                format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
209            }
210        }
211        crate::config::DependencyMode::Local => {
212            if crate_name != dep_name {
213                format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
214            } else if effective_features.is_empty() {
215                format!("{dep_name} = {{ path = \"{crate_path}\" }}")
216            } else {
217                format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
218            }
219        }
220    };
221    // serde_json is needed either when args use json_object/handle, or when the
222    // mock server binary is present (it uses serde_json::Value for fixture bodies),
223    // or when http integration tests are generated (they serialize fixture bodies).
224    let effective_needs_serde_json = needs_serde_json || needs_mock_server || needs_http_tests;
225    let serde_line = if effective_needs_serde_json {
226        "\nserde_json = \"1\""
227    } else {
228        ""
229    };
230    // An empty `[workspace]` table makes the e2e crate its own workspace root, so
231    // it never gets pulled into a parent crate's workspace. This means consumers
232    // don't have to remember to add `e2e/rust` to `workspace.exclude`, and
233    // `cargo fmt`/`cargo build` work the same whether the parent has a
234    // workspace or not.
235    // Mock server requires axum (HTTP router) and tokio-stream (SSE streaming).
236    // The standalone binary additionally needs serde (derive) and walkdir.
237    // Http integration tests require axum-test for the test server.
238    let needs_axum = needs_mock_server || needs_http_tests;
239    let mock_lines = if needs_axum {
240        let mut lines = format!(
241            "\naxum = \"{axum}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
242            axum = tv::cargo::AXUM,
243            walkdir = tv::cargo::WALKDIR,
244        );
245        if needs_mock_server {
246            lines.push_str(&format!(
247                "\ntokio-stream = \"{tokio_stream}\"",
248                tokio_stream = tv::cargo::TOKIO_STREAM
249            ));
250        }
251        if needs_http_tests {
252            lines.push_str("\naxum-test = \"20\"\nbytes = \"1\"");
253        }
254        lines
255    } else {
256        String::new()
257    };
258    let mut machete_ignored: Vec<&str> = Vec::new();
259    if effective_needs_serde_json {
260        machete_ignored.push("\"serde_json\"");
261    }
262    if needs_axum {
263        machete_ignored.push("\"axum\"");
264        machete_ignored.push("\"serde\"");
265        machete_ignored.push("\"walkdir\"");
266    }
267    if needs_mock_server {
268        machete_ignored.push("\"tokio-stream\"");
269    }
270    if needs_http_tests {
271        machete_ignored.push("\"axum-test\"");
272        machete_ignored.push("\"bytes\"");
273    }
274    let machete_section = if machete_ignored.is_empty() {
275        String::new()
276    } else {
277        format!(
278            "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
279            machete_ignored.join(", ")
280        )
281    };
282    let tokio_line = if needs_tokio {
283        "\ntokio = { version = \"1\", features = [\"full\"] }"
284    } else {
285        ""
286    };
287    let bin_section = if needs_mock_server || needs_http_tests {
288        "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
289    } else {
290        ""
291    };
292    let header = hash::header(CommentStyle::Hash);
293    format!(
294        r#"{header}
295[workspace]
296
297[package]
298name = "{e2e_name}"
299version = "0.1.0"
300edition = "2021"
301license = "MIT"
302publish = false
303{bin_section}
304[dependencies]
305{dep_spec}{serde_line}{mock_lines}{tokio_line}
306{machete_section}"#
307    )
308}
309
310fn render_test_file(
311    category: &str,
312    fixtures: &[&Fixture],
313    e2e_config: &E2eConfig,
314    dep_name: &str,
315    needs_mock_server: bool,
316) -> String {
317    let mut out = String::new();
318    out.push_str(&hash::header(CommentStyle::DoubleSlash));
319    let _ = writeln!(out, "//! E2e tests for category: {category}");
320    let _ = writeln!(out);
321
322    let module = resolve_module(e2e_config, dep_name);
323    let field_resolver = FieldResolver::new(
324        &e2e_config.fields,
325        &e2e_config.fields_optional,
326        &e2e_config.result_fields,
327        &e2e_config.fields_array,
328    );
329
330    // Check if this file has http-fixture tests (separate from call-based tests).
331    let file_has_http = fixtures.iter().any(|f| f.http.is_some());
332    // Call-based: has mock_response (liter-llm style), NOT pure stub fixtures.
333    // Pure stub fixtures (neither http nor mock_response) use a stub path — no function import.
334    let file_has_call_based = fixtures.iter().any(|f| f.mock_response.is_some());
335
336    // Collect all unique (module, function) pairs needed across call-based fixtures only.
337    // Http fixtures and stub fixtures use different code paths and don't import the call function.
338    if file_has_call_based {
339        let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
340        for fixture in fixtures.iter().filter(|f| f.mock_response.is_some()) {
341            let call_config = e2e_config.resolve_call(fixture.call.as_deref());
342            let fn_name = resolve_function_name_for_call(call_config);
343            let mod_name = resolve_module_for_call(call_config, dep_name);
344            imported.insert((mod_name, fn_name));
345        }
346        // Emit use statements, grouping by module when possible.
347        let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
348        for (mod_name, fn_name) in &imported {
349            by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
350        }
351        for (mod_name, fns) in &by_module {
352            if fns.len() == 1 {
353                let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
354            } else {
355                let joined = fns.join(", ");
356                let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
357            }
358        }
359    }
360
361    // Http fixtures use App + RequestContext for integration tests.
362    if file_has_http {
363        let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
364    }
365
366    // Import handle constructor functions and the config type they use.
367    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
368    if has_handle_args {
369        let _ = writeln!(out, "use {module}::CrawlConfig;");
370    }
371    for arg in &e2e_config.call.args {
372        if arg.arg_type == "handle" {
373            use heck::ToSnakeCase;
374            let constructor_name = format!("create_{}", arg.name.to_snake_case());
375            let _ = writeln!(out, "use {module}::{constructor_name};");
376        }
377    }
378
379    // Import mock_server module when any fixture in this file uses mock_response.
380    let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.mock_response.is_some());
381    if file_needs_mock {
382        let _ = writeln!(out, "mod mock_server;");
383        let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
384    }
385
386    // Import the visitor trait, result enum, and node context when any fixture
387    // in this file declares a `visitor` block. Without these, the inline
388    // `impl HtmlVisitor for _TestVisitor` block fails to resolve.
389    let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
390    if file_needs_visitor {
391        let visitor_trait = resolve_visitor_trait(&module);
392        let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
393    }
394
395    let _ = writeln!(out);
396
397    for fixture in fixtures {
398        render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
399        let _ = writeln!(out);
400    }
401
402    if !out.ends_with('\n') {
403        out.push('\n');
404    }
405    out
406}
407
408fn render_test_function(
409    out: &mut String,
410    fixture: &Fixture,
411    e2e_config: &E2eConfig,
412    dep_name: &str,
413    field_resolver: &FieldResolver,
414) {
415    // Http fixtures get their own integration test code path.
416    if fixture.http.is_some() {
417        render_http_test_function(out, fixture, dep_name);
418        return;
419    }
420
421    // Fixtures that have neither `http` nor `mock_response` are schema/spec
422    // validation fixtures (asyncapi, grpc, graphql_schema, etc.). These don't
423    // yet have a callable function in the Rust e2e suite — generate a stub
424    // that compiles and passes to preserve test count without breaking builds.
425    if fixture.http.is_none() && fixture.mock_response.is_none() {
426        let fn_name = sanitize_ident(&fixture.id);
427        let description = &fixture.description;
428        let _ = writeln!(out, "#[tokio::test]");
429        let _ = writeln!(out, "async fn test_{fn_name}() {{");
430        let _ = writeln!(out, "    // {description}");
431        let _ = writeln!(
432            out,
433            "    // TODO: implement when a callable API is available for this fixture type."
434        );
435        let _ = writeln!(out, "}}");
436        return;
437    }
438
439    let fn_name = sanitize_ident(&fixture.id);
440    let description = &fixture.description;
441    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
442    let function_name = resolve_function_name_for_call(call_config);
443    let module = resolve_module_for_call(call_config, dep_name);
444    let result_var = &call_config.result_var;
445    let has_mock = fixture.mock_response.is_some();
446
447    // Tests with a mock server are always async (Axum requires a Tokio runtime).
448    let is_async = call_config.r#async || has_mock;
449    if is_async {
450        let _ = writeln!(out, "#[tokio::test]");
451        let _ = writeln!(out, "async fn test_{fn_name}() {{");
452    } else {
453        let _ = writeln!(out, "#[test]");
454        let _ = writeln!(out, "fn test_{fn_name}() {{");
455    }
456    let _ = writeln!(out, "    // {description}");
457
458    // Emit mock server setup before building arguments so arg expressions can
459    // reference `mock_server.url` when needed.
460    if has_mock {
461        render_mock_server_setup(out, fixture, e2e_config);
462    }
463
464    // Check if any assertion is an error assertion.
465    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
466
467    // Resolve Rust-specific overrides for argument shaping.
468    let rust_overrides = call_config.overrides.get("rust");
469    let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
470    let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
471
472    // Emit input variable bindings from args config.
473    let mut arg_exprs: Vec<String> = Vec::new();
474    for arg in &call_config.args {
475        let value = resolve_field(&fixture.input, &arg.field);
476        let var_name = &arg.name;
477        let (bindings, expr) = render_rust_arg(
478            var_name,
479            value,
480            &arg.arg_type,
481            arg.optional,
482            &module,
483            &fixture.id,
484            if has_mock {
485                Some("mock_server.url.as_str()")
486            } else {
487                None
488            },
489            arg.owned,
490            arg.element_type.as_deref(),
491        );
492        for binding in &bindings {
493            let _ = writeln!(out, "    {binding}");
494        }
495        // For functions whose options slot is owned `Option<T>` rather than `&T`,
496        // wrap the json_object expression in `Some(...).clone()` so it matches
497        // the parameter shape. Other arg types pass through unchanged.
498        let final_expr = if wrap_options_in_some && arg.arg_type == "json_object" {
499            if let Some(rest) = expr.strip_prefix('&') {
500                format!("Some({rest}.clone())")
501            } else {
502                format!("Some({expr})")
503            }
504        } else {
505            expr
506        };
507        arg_exprs.push(final_expr);
508    }
509
510    // Emit visitor if present in fixture.
511    if let Some(visitor_spec) = &fixture.visitor {
512        let _ = writeln!(out, "    struct _TestVisitor;");
513        let _ = writeln!(out, "    impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
514        for (method_name, action) in &visitor_spec.callbacks {
515            emit_rust_visitor_method(out, method_name, action);
516        }
517        let _ = writeln!(out, "    }}");
518        let _ = writeln!(
519            out,
520            "    let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
521        );
522        arg_exprs.push("Some(visitor)".to_string());
523    } else {
524        // No fixture-supplied visitor: append any extra positional args declared in
525        // the rust override (e.g. trailing `None` for an Option<VisitorParam> slot).
526        arg_exprs.extend(extra_args);
527    }
528
529    let args_str = arg_exprs.join(", ");
530
531    let await_suffix = if is_async { ".await" } else { "" };
532
533    let result_is_tree = call_config.result_var == "tree";
534    // When the rust override sets result_is_simple, the function returns a plain type
535    // (String, Vec<T>, etc.) — field-access assertions use the result var directly.
536    let result_is_simple = rust_overrides.is_some_and(|o| o.result_is_simple);
537    // When result_is_vec is set, the function returns Vec<T>. Field-path assertions
538    // are wrapped in `.iter().all(|r| ...)` so every element is checked.
539    let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
540    // When result_is_option is set, the function returns Option<T>. Field-path
541    // assertions unwrap first via `.as_ref().expect("Option should be Some")`.
542    let result_is_option = rust_overrides.is_some_and(|o| o.result_is_option);
543
544    if has_error_assertion {
545        let _ = writeln!(out, "    let {result_var} = {function_name}({args_str}){await_suffix};");
546        // Render error assertions.
547        for assertion in &fixture.assertions {
548            render_assertion(
549                out,
550                assertion,
551                result_var,
552                &module,
553                dep_name,
554                true,
555                &[],
556                field_resolver,
557                result_is_tree,
558                result_is_simple,
559                false,
560                false,
561            );
562        }
563        let _ = writeln!(out, "}}");
564        return;
565    }
566
567    // Non-error path: unwrap the result.
568    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
569
570    // Check if any assertion actually uses the result variable.
571    // If all assertions are skipped (field not on result type), use `_` to avoid
572    // Rust's "variable never used" warning.
573    let has_usable_assertion = fixture.assertions.iter().any(|a| {
574        if a.assertion_type == "not_error" || a.assertion_type == "error" {
575            return false;
576        }
577        if a.assertion_type == "method_result" {
578            // method_result assertions that would generate only a TODO comment don't use the
579            // result variable. These are: missing `method` field, or unsupported `check` type.
580            let supported_checks = [
581                "equals",
582                "is_true",
583                "is_false",
584                "greater_than_or_equal",
585                "count_min",
586                "is_error",
587                "contains",
588                "not_empty",
589                "is_empty",
590            ];
591            let check = a.check.as_deref().unwrap_or("is_true");
592            if a.method.is_none() || !supported_checks.contains(&check) {
593                return false;
594            }
595        }
596        match &a.field {
597            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
598            _ => true,
599        }
600    });
601
602    let result_binding = if has_usable_assertion {
603        result_var.to_string()
604    } else {
605        "_".to_string()
606    };
607
608    // Detect Option-returning functions: only skip unwrap when ALL assertions are
609    // pure emptiness/bool checks with NO field access (is_none/is_some on the result itself).
610    // If any assertion accesses a field (e.g. `html`), we need the inner value, so unwrap.
611    let has_field_access = fixture
612        .assertions
613        .iter()
614        .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
615    let only_emptiness_checks = !has_field_access
616        && fixture.assertions.iter().all(|a| {
617            matches!(
618                a.assertion_type.as_str(),
619                "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
620            )
621        });
622
623    // Per-rust override of the call-level `returns_result`. When set, takes
624    // precedence over `CallConfig.returns_result` for the Rust generator only.
625    let returns_result = rust_overrides
626        .and_then(|o| o.returns_result)
627        .unwrap_or(call_config.returns_result);
628
629    let unwrap_suffix = if returns_result {
630        ".expect(\"should succeed\")"
631    } else {
632        ""
633    };
634    if only_emptiness_checks || !returns_result {
635        // Option-returning or non-Result-returning: bind raw value, no unwrap.
636        let _ = writeln!(
637            out,
638            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
639        );
640    } else if has_not_error || !fixture.assertions.is_empty() {
641        let _ = writeln!(
642            out,
643            "    let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
644        );
645    } else {
646        let _ = writeln!(
647            out,
648            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
649        );
650    }
651
652    // Emit Option field unwrap bindings for any fields accessed in assertions.
653    // Use FieldResolver to handle optional fields, including nested/aliased paths.
654    // Skipped when the call returns Vec<T>: per-element iteration is emitted by
655    // `render_assertion` itself, so the call-site has no single result struct
656    // to unwrap fields off of.
657    let string_assertion_types = [
658        "equals",
659        "contains",
660        "contains_all",
661        "contains_any",
662        "not_contains",
663        "starts_with",
664        "ends_with",
665        "min_length",
666        "max_length",
667        "matches_regex",
668    ];
669    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
670    if !result_is_vec {
671        for assertion in &fixture.assertions {
672            if let Some(f) = &assertion.field {
673                if !f.is_empty()
674                    && string_assertion_types.contains(&assertion.assertion_type.as_str())
675                    && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
676                {
677                    // Only unwrap optional string fields — numeric optionals (u64, usize)
678                    // don't support .as_deref() and should be compared directly.
679                    let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
680                    if !is_string_assertion {
681                        continue;
682                    }
683                    if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
684                        let _ = writeln!(out, "    {binding}");
685                        unwrapped_fields.push((f.clone(), local_var));
686                    }
687                }
688            }
689        }
690    }
691
692    // Render assertions.
693    for assertion in &fixture.assertions {
694        if assertion.assertion_type == "not_error" {
695            // Already handled by .expect() above.
696            continue;
697        }
698        render_assertion(
699            out,
700            assertion,
701            result_var,
702            &module,
703            dep_name,
704            false,
705            &unwrapped_fields,
706            field_resolver,
707            result_is_tree,
708            result_is_simple,
709            result_is_vec,
710            result_is_option,
711        );
712    }
713
714    let _ = writeln!(out, "}}");
715}
716
717// ---------------------------------------------------------------------------
718// Argument rendering
719// ---------------------------------------------------------------------------
720
721#[allow(clippy::too_many_arguments)]
722fn render_rust_arg(
723    name: &str,
724    value: &serde_json::Value,
725    arg_type: &str,
726    optional: bool,
727    module: &str,
728    fixture_id: &str,
729    mock_base_url: Option<&str>,
730    owned: bool,
731    element_type: Option<&str>,
732) -> (Vec<String>, String) {
733    if arg_type == "mock_url" {
734        let lines = vec![format!(
735            "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
736        )];
737        return (lines, format!("&{name}"));
738    }
739    // When the arg is a base_url and a mock server is running, use the mock server URL.
740    if arg_type == "base_url" {
741        if let Some(url_expr) = mock_base_url {
742            return (vec![], url_expr.to_string());
743        }
744        // No mock server: fall through to string handling below.
745    }
746    if arg_type == "handle" {
747        // Generate a create_engine (or equivalent) call and pass the config.
748        // If the fixture has input.config, serialize it as a json_object and pass it;
749        // otherwise pass None.
750        use heck::ToSnakeCase;
751        let constructor_name = format!("create_{}", name.to_snake_case());
752        let mut lines = Vec::new();
753        if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
754            lines.push(format!(
755                "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
756            ));
757        } else {
758            // Serialize the config JSON and deserialize at runtime.
759            let json_literal = serde_json::to_string(value).unwrap_or_default();
760            let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
761            lines.push(format!(
762                "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
763            ));
764            lines.push(format!(
765                "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
766            ));
767        }
768        return (lines, format!("&{name}"));
769    }
770    if arg_type == "json_object" {
771        return render_json_object_arg(name, value, optional, owned, element_type, module);
772    }
773    if value.is_null() && !optional {
774        // Required arg with no fixture value: use a language-appropriate default.
775        let default_val = match arg_type {
776            "string" => "String::new()".to_string(),
777            "int" | "integer" => "0".to_string(),
778            "float" | "number" => "0.0_f64".to_string(),
779            "bool" | "boolean" => "false".to_string(),
780            _ => "Default::default()".to_string(),
781        };
782        // String args are passed by reference in Rust.
783        let expr = if arg_type == "string" {
784            format!("&{name}")
785        } else {
786            name.to_string()
787        };
788        return (vec![format!("let {name} = {default_val};")], expr);
789    }
790    let literal = json_to_rust_literal(value, arg_type);
791    // String args are raw string literals (`r#"..."#`) — already `&str`, no extra `&` needed.
792    // Bytes args are passed by reference using `.as_bytes()` in the `expr` closure below.
793    let pass_by_ref = arg_type == "bytes";
794    let optional_expr = |n: &str| {
795        if arg_type == "string" {
796            format!("{n}.as_deref()")
797        } else if arg_type == "bytes" {
798            format!("{n}.as_deref().map(|v| v.as_slice())")
799        } else {
800            // Owned numeric / bool / generic: pass the Option<T> by value.
801            // Function signature shape `Option<T>` matches without `.as_ref()`,
802            // which would produce `Option<&T>` and fail to coerce.
803            n.to_string()
804        }
805    };
806    let expr = |n: &str| {
807        if arg_type == "bytes" {
808            format!("{n}.as_bytes()")
809        } else if pass_by_ref {
810            format!("&{n}")
811        } else {
812            n.to_string()
813        }
814    };
815    if optional && value.is_null() {
816        let none_decl = match arg_type {
817            "string" => format!("let {name}: Option<String> = None;"),
818            "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
819            _ => format!("let {name} = None;"),
820        };
821        (vec![none_decl], optional_expr(name))
822    } else if optional {
823        (vec![format!("let {name} = Some({literal});")], optional_expr(name))
824    } else {
825        (vec![format!("let {name} = {literal};")], expr(name))
826    }
827}
828
829/// Render a `json_object` argument: serialize the fixture JSON as a `serde_json::json!` literal
830/// and deserialize it through serde at runtime. Type inference from the function signature
831/// determines the concrete type, keeping the generator generic.
832///
833/// `owned` — when true the binding is passed by value (no leading `&`); use for `Vec<T>` params.
834/// `element_type` — when set, emits `Vec<element_type>` annotation to satisfy type inference for
835///   `&[T]` parameters where `serde_json::from_value` cannot resolve the unsized slice type.
836fn render_json_object_arg(
837    name: &str,
838    value: &serde_json::Value,
839    optional: bool,
840    owned: bool,
841    element_type: Option<&str>,
842    _module: &str,
843) -> (Vec<String>, String) {
844    // Owned params (Vec<T>) are passed by value; ref params (most configs) use &.
845    let pass_by_ref = !owned;
846
847    if value.is_null() && optional {
848        // Use Default::default() — Rust functions take &T (or T for owned), not Option<T>.
849        let expr = if pass_by_ref {
850            format!("&{name}")
851        } else {
852            name.to_string()
853        };
854        return (vec![format!("let {name} = Default::default();")], expr);
855    }
856
857    // Fixture keys are camelCase; the Rust ConversionOptions type uses snake_case serde.
858    // Normalize keys before building the json! literal so deserialization succeeds.
859    let normalized = super::normalize_json_keys_to_snake_case(value);
860    // Build the json! macro invocation from the fixture object.
861    let json_literal = json_value_to_macro_literal(&normalized);
862    let mut lines = Vec::new();
863    lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
864
865    // When an explicit element type is given, annotate with Vec<T> so that
866    // serde_json::from_value can infer the element type for &[T] parameters (A4 fix).
867    let deser_expr = if let Some(elem) = element_type {
868        format!("serde_json::from_value::<Vec<{elem}>>({name}_json).unwrap()")
869    } else {
870        format!("serde_json::from_value({name}_json).unwrap()")
871    };
872
873    // A1 fix: always deser as T (never wrap in Some()); optional non-null args target
874    // &T not &Option<T>. Pass as &T (ref) or T (owned) depending on the `owned` flag.
875    lines.push(format!("let {name} = {deser_expr};"));
876    let expr = if pass_by_ref {
877        format!("&{name}")
878    } else {
879        name.to_string()
880    };
881    (lines, expr)
882}
883
884/// Convert a `serde_json::Value` into a string suitable for the `serde_json::json!()` macro.
885fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
886    match value {
887        serde_json::Value::Null => "null".to_string(),
888        serde_json::Value::Bool(b) => format!("{b}"),
889        serde_json::Value::Number(n) => n.to_string(),
890        serde_json::Value::String(s) => {
891            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
892            format!("\"{escaped}\"")
893        }
894        serde_json::Value::Array(arr) => {
895            let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
896            format!("[{}]", items.join(", "))
897        }
898        serde_json::Value::Object(obj) => {
899            let entries: Vec<String> = obj
900                .iter()
901                .map(|(k, v)| {
902                    let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
903                    format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
904                })
905                .collect();
906            format!("{{{}}}", entries.join(", "))
907        }
908    }
909}
910
911fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
912    match value {
913        serde_json::Value::Null => "None".to_string(),
914        serde_json::Value::Bool(b) => format!("{b}"),
915        serde_json::Value::Number(n) => {
916            if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
917                if let Some(f) = n.as_f64() {
918                    return format!("{f}_f64");
919                }
920            }
921            n.to_string()
922        }
923        serde_json::Value::String(s) => rust_raw_string(s),
924        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
925            let json_str = serde_json::to_string(value).unwrap_or_default();
926            let literal = rust_raw_string(&json_str);
927            format!("serde_json::from_str({literal}).unwrap()")
928        }
929    }
930}
931
932// ---------------------------------------------------------------------------
933// Http integration test helpers
934// ---------------------------------------------------------------------------
935
936/// Generate a complete integration test function for an http fixture.
937///
938/// Builds a real spikard `App` with a handler that returns the expected
939/// response, then uses `axum_test::TestServer` to send the request and
940/// assert the status code.
941fn render_http_test_function(out: &mut String, fixture: &Fixture, dep_name: &str) {
942    let http = match &fixture.http {
943        Some(h) => h,
944        None => return,
945    };
946
947    let fn_name = sanitize_ident(&fixture.id);
948    let description = &fixture.description;
949
950    let route = &http.handler.route;
951
952    // spikard provides convenience functions for GET/POST/PUT/PATCH/DELETE.
953    // All other methods (HEAD, OPTIONS, TRACE, etc.) must use RouteBuilder::new directly.
954    enum RouteRegistration<'a> {
955        /// Use `spikard::get(path)` / `spikard::post(path)` etc.
956        Shorthand(&'a str),
957        /// Use `spikard::RouteBuilder::new(spikard::Method::Head, path)` etc.
958        Explicit(&'a str),
959    }
960    let route_reg = match http.handler.method.to_lowercase().as_str() {
961        "get" => RouteRegistration::Shorthand("get"),
962        "post" => RouteRegistration::Shorthand("post"),
963        "put" => RouteRegistration::Shorthand("put"),
964        "patch" => RouteRegistration::Shorthand("patch"),
965        "delete" => RouteRegistration::Shorthand("delete"),
966        "head" => RouteRegistration::Explicit("Head"),
967        "options" => RouteRegistration::Explicit("Options"),
968        "trace" => RouteRegistration::Explicit("Trace"),
969        _ => RouteRegistration::Shorthand("get"),
970    };
971
972    // axum_test::TestServer has shorthand methods for GET/POST/PUT/PATCH/DELETE.
973    // For HEAD and other methods, use server.method(axum::http::Method::HEAD, path).
974    enum ServerCall<'a> {
975        /// Use `server.get(path)` / `server.post(path)` etc.
976        Shorthand(&'a str),
977        /// Use `server.method(axum::http::Method::HEAD, path)` etc.
978        AxumMethod(&'a str),
979    }
980    let server_call = match http.request.method.to_uppercase().as_str() {
981        "GET" => ServerCall::Shorthand("get"),
982        "POST" => ServerCall::Shorthand("post"),
983        "PUT" => ServerCall::Shorthand("put"),
984        "PATCH" => ServerCall::Shorthand("patch"),
985        "DELETE" => ServerCall::Shorthand("delete"),
986        "HEAD" => ServerCall::AxumMethod("HEAD"),
987        "OPTIONS" => ServerCall::AxumMethod("OPTIONS"),
988        "TRACE" => ServerCall::AxumMethod("TRACE"),
989        _ => ServerCall::Shorthand("get"),
990    };
991
992    let req_path = &http.request.path;
993    let status = http.expected_response.status_code;
994
995    // Serialize expected response body (if any).
996    let body_str = match &http.expected_response.body {
997        Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
998        None => String::new(),
999    };
1000    let body_literal = rust_raw_string(&body_str);
1001
1002    // Serialize request body (if any).
1003    let req_body_str = match &http.request.body {
1004        Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1005        None => String::new(),
1006    };
1007    let has_req_body = !req_body_str.is_empty();
1008
1009    let _ = writeln!(out, "#[tokio::test]");
1010    let _ = writeln!(out, "async fn test_{fn_name}() {{");
1011    let _ = writeln!(out, "    // {description}");
1012
1013    // Build handler that returns the expected response.
1014    let _ = writeln!(out, "    let expected_body = {body_literal}.to_string();");
1015    let _ = writeln!(out, "    let mut app = {dep_name}::App::new();");
1016
1017    // Emit route registration.
1018    match &route_reg {
1019        RouteRegistration::Shorthand(method) => {
1020            let _ = writeln!(
1021                out,
1022                "    app.route({dep_name}::{method}({route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1023            );
1024        }
1025        RouteRegistration::Explicit(variant) => {
1026            let _ = writeln!(
1027                out,
1028                "    app.route({dep_name}::RouteBuilder::new({dep_name}::Method::{variant}, {route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1029            );
1030        }
1031    }
1032    let _ = writeln!(out, "        let body = expected_body.clone();");
1033    let _ = writeln!(out, "        async move {{");
1034    let _ = writeln!(out, "            Ok(axum::http::Response::builder()");
1035    let _ = writeln!(out, "                .status({status}u16)");
1036    let _ = writeln!(out, "                .header(\"content-type\", \"application/json\")");
1037    let _ = writeln!(out, "                .body(axum::body::Body::from(body))");
1038    let _ = writeln!(out, "                .unwrap())");
1039    let _ = writeln!(out, "        }}");
1040    let _ = writeln!(out, "    }}).unwrap();");
1041
1042    // Build axum-test TestServer from the app router.
1043    let _ = writeln!(out, "    let router = app.into_router().unwrap();");
1044    let _ = writeln!(out, "    let server = axum_test::TestServer::new(router);");
1045
1046    // Build and send the request.
1047    match &server_call {
1048        ServerCall::Shorthand(method) => {
1049            let _ = writeln!(out, "    let response = server.{method}({req_path:?})");
1050        }
1051        ServerCall::AxumMethod(method) => {
1052            let _ = writeln!(
1053                out,
1054                "    let response = server.method(axum::http::Method::{method}, {req_path:?})"
1055            );
1056        }
1057    }
1058
1059    // Add request headers (axum_test::TestRequest::add_header accepts &str via TryInto).
1060    for (name, value) in &http.request.headers {
1061        let n = rust_raw_string(name);
1062        let v = rust_raw_string(value);
1063        let _ = writeln!(out, "        .add_header({n}, {v})");
1064    }
1065
1066    // Add request body if present (pass as a JSON string so axum-test's bytes() API gets a Bytes value).
1067    if has_req_body {
1068        let req_body_literal = rust_raw_string(&req_body_str);
1069        let _ = writeln!(
1070            out,
1071            "        .bytes(bytes::Bytes::copy_from_slice({req_body_literal}.as_bytes()))"
1072        );
1073    }
1074
1075    let _ = writeln!(out, "        .await;");
1076
1077    // Assert status code.
1078    let _ = writeln!(out, "    assert_eq!(response.status_code().as_u16(), {status}u16);");
1079
1080    let _ = writeln!(out, "}}");
1081}
1082
1083// ---------------------------------------------------------------------------
1084// Mock server helpers
1085// ---------------------------------------------------------------------------
1086
1087/// Emit mock server setup lines into a test function body.
1088///
1089/// Builds `MockRoute` objects from the fixture's `mock_response` and starts
1090/// the server.  The resulting `mock_server` variable is in scope for the rest
1091/// of the test function.
1092fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
1093    let mock = match fixture.mock_response.as_ref() {
1094        Some(m) => m,
1095        None => return,
1096    };
1097
1098    // Resolve the HTTP path and method from the call config.
1099    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1100    let path = call_config.path.as_deref().unwrap_or("/");
1101    let method = call_config.method.as_deref().unwrap_or("POST");
1102
1103    let status = mock.status;
1104
1105    // Render headers map as a Vec<(String, String)> literal for stable iteration order.
1106    let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
1107    header_entries.sort_by(|a, b| a.0.cmp(b.0));
1108    let render_headers = |out: &mut String| {
1109        let _ = writeln!(out, "        headers: vec![");
1110        for (name, value) in &header_entries {
1111            let n = rust_raw_string(name);
1112            let v = rust_raw_string(value);
1113            let _ = writeln!(out, "            ({n}.to_string(), {v}.to_string()),");
1114        }
1115        let _ = writeln!(out, "        ],");
1116    };
1117
1118    if let Some(chunks) = &mock.stream_chunks {
1119        // Streaming SSE response.
1120        let _ = writeln!(out, "    let mock_route = MockRoute {{");
1121        let _ = writeln!(out, "        path: \"{path}\",");
1122        let _ = writeln!(out, "        method: \"{method}\",");
1123        let _ = writeln!(out, "        status: {status},");
1124        let _ = writeln!(out, "        body: String::new(),");
1125        let _ = writeln!(out, "        stream_chunks: vec![");
1126        for chunk in chunks {
1127            let chunk_str = match chunk {
1128                serde_json::Value::String(s) => rust_raw_string(s),
1129                other => {
1130                    let s = serde_json::to_string(other).unwrap_or_default();
1131                    rust_raw_string(&s)
1132                }
1133            };
1134            let _ = writeln!(out, "            {chunk_str}.to_string(),");
1135        }
1136        let _ = writeln!(out, "        ],");
1137        render_headers(out);
1138        let _ = writeln!(out, "    }};");
1139    } else {
1140        // Non-streaming JSON response.
1141        let body_str = match &mock.body {
1142            Some(b) => {
1143                let s = serde_json::to_string(b).unwrap_or_default();
1144                rust_raw_string(&s)
1145            }
1146            None => rust_raw_string("{}"),
1147        };
1148        let _ = writeln!(out, "    let mock_route = MockRoute {{");
1149        let _ = writeln!(out, "        path: \"{path}\",");
1150        let _ = writeln!(out, "        method: \"{method}\",");
1151        let _ = writeln!(out, "        status: {status},");
1152        let _ = writeln!(out, "        body: {body_str}.to_string(),");
1153        let _ = writeln!(out, "        stream_chunks: vec![],");
1154        render_headers(out);
1155        let _ = writeln!(out, "    }};");
1156    }
1157
1158    let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
1159}
1160
1161/// Generate the complete `mock_server.rs` module source.
1162pub fn render_mock_server_module() -> String {
1163    // This is parameterized Axum mock server code identical in structure to
1164    // liter-llm's mock_server.rs but without any project-specific imports.
1165    hash::header(CommentStyle::DoubleSlash)
1166        + r#"//
1167// Minimal axum-based mock HTTP server for e2e tests.
1168
1169use std::net::SocketAddr;
1170use std::sync::Arc;
1171
1172use axum::Router;
1173use axum::body::Body;
1174use axum::extract::State;
1175use axum::http::{Request, StatusCode};
1176use axum::response::{IntoResponse, Response};
1177use tokio::net::TcpListener;
1178
1179/// A single mock route: match by path + method, return a configured response.
1180#[derive(Clone, Debug)]
1181pub struct MockRoute {
1182    /// URL path to match, e.g. `"/v1/chat/completions"`.
1183    pub path: &'static str,
1184    /// HTTP method to match, e.g. `"POST"` or `"GET"`.
1185    pub method: &'static str,
1186    /// HTTP status code to return.
1187    pub status: u16,
1188    /// Response body JSON string (used when `stream_chunks` is empty).
1189    pub body: String,
1190    /// Ordered SSE data payloads for streaming responses.
1191    /// Each entry becomes `data: <chunk>\n\n` in the response.
1192    /// A final `data: [DONE]\n\n` is always appended.
1193    pub stream_chunks: Vec<String>,
1194    /// Response headers to apply (name, value) pairs.
1195    /// Multiple entries with the same name produce multiple header lines.
1196    pub headers: Vec<(String, String)>,
1197}
1198
1199struct ServerState {
1200    routes: Vec<MockRoute>,
1201}
1202
1203pub struct MockServer {
1204    /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
1205    pub url: String,
1206    handle: tokio::task::JoinHandle<()>,
1207}
1208
1209impl MockServer {
1210    /// Start a mock server with the given routes.  Binds to a random port on
1211    /// localhost and returns immediately once the server is listening.
1212    pub async fn start(routes: Vec<MockRoute>) -> Self {
1213        let state = Arc::new(ServerState { routes });
1214
1215        let app = Router::new().fallback(handle_request).with_state(state);
1216
1217        let listener = TcpListener::bind("127.0.0.1:0")
1218            .await
1219            .expect("Failed to bind mock server port");
1220        let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
1221        let url = format!("http://{addr}");
1222
1223        let handle = tokio::spawn(async move {
1224            axum::serve(listener, app).await.expect("Mock server failed");
1225        });
1226
1227        MockServer { url, handle }
1228    }
1229
1230    /// Stop the mock server.
1231    pub fn shutdown(self) {
1232        self.handle.abort();
1233    }
1234}
1235
1236impl Drop for MockServer {
1237    fn drop(&mut self) {
1238        self.handle.abort();
1239    }
1240}
1241
1242async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
1243    let path = req.uri().path().to_owned();
1244    let method = req.method().as_str().to_uppercase();
1245
1246    for route in &state.routes {
1247        if route.path == path && route.method.to_uppercase() == method {
1248            let status =
1249                StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1250
1251            if !route.stream_chunks.is_empty() {
1252                // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
1253                let mut sse = String::new();
1254                for chunk in &route.stream_chunks {
1255                    sse.push_str("data: ");
1256                    sse.push_str(chunk);
1257                    sse.push_str("\n\n");
1258                }
1259                sse.push_str("data: [DONE]\n\n");
1260
1261                let mut builder = Response::builder()
1262                    .status(status)
1263                    .header("content-type", "text/event-stream")
1264                    .header("cache-control", "no-cache");
1265                for (name, value) in &route.headers {
1266                    builder = builder.header(name, value);
1267                }
1268                return builder.body(Body::from(sse)).unwrap().into_response();
1269            }
1270
1271            let mut builder =
1272                Response::builder().status(status).header("content-type", "application/json");
1273            for (name, value) in &route.headers {
1274                builder = builder.header(name, value);
1275            }
1276            return builder.body(Body::from(route.body.clone())).unwrap().into_response();
1277        }
1278    }
1279
1280    // No matching route → 404.
1281    Response::builder()
1282        .status(StatusCode::NOT_FOUND)
1283        .body(Body::from(format!("No mock route for {method} {path}")))
1284        .unwrap()
1285        .into_response()
1286}
1287"#
1288}
1289
1290/// Generate the `src/main.rs` for the standalone mock server binary.
1291///
1292/// The binary:
1293/// - Reads all `*.json` fixture files from a fixtures directory (default `../../fixtures`).
1294/// - For each fixture that has a `mock_response` field, registers a route at
1295///   `/fixtures/{fixture_id}` returning the configured status/body/SSE chunks.
1296/// - Binds to `127.0.0.1:0` (random port), prints `MOCK_SERVER_URL=http://...`
1297///   to stdout, then waits until stdin is closed for clean teardown.
1298///
1299/// This binary is intended for cross-language e2e suites (WASM, Node) that
1300/// spawn it as a child process and read the URL from its stdout.
1301pub fn render_mock_server_binary() -> String {
1302    hash::header(CommentStyle::DoubleSlash)
1303        + r#"//
1304// Standalone mock HTTP server binary for cross-language e2e tests.
1305// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
1306//
1307// Usage: mock-server [fixtures-dir]
1308//   fixtures-dir defaults to "../../fixtures"
1309//
1310// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
1311// then blocks until stdin is closed (parent process exit triggers cleanup).
1312
1313use std::collections::HashMap;
1314use std::io::{self, BufRead};
1315use std::net::SocketAddr;
1316use std::path::Path;
1317use std::sync::Arc;
1318
1319use axum::Router;
1320use axum::body::Body;
1321use axum::extract::State;
1322use axum::http::{Request, StatusCode};
1323use axum::response::{IntoResponse, Response};
1324use serde::Deserialize;
1325use tokio::net::TcpListener;
1326
1327// ---------------------------------------------------------------------------
1328// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1329// Supports both schemas:
1330//   liter-llm: mock_response: { status, body, stream_chunks }
1331//   spikard:   http.expected_response: { status_code, body, headers }
1332// ---------------------------------------------------------------------------
1333
1334#[derive(Debug, Deserialize)]
1335struct MockResponse {
1336    status: u16,
1337    #[serde(default)]
1338    body: Option<serde_json::Value>,
1339    #[serde(default)]
1340    stream_chunks: Option<Vec<serde_json::Value>>,
1341    #[serde(default)]
1342    headers: HashMap<String, String>,
1343}
1344
1345#[derive(Debug, Deserialize)]
1346struct HttpExpectedResponse {
1347    status_code: u16,
1348    #[serde(default)]
1349    body: Option<serde_json::Value>,
1350    #[serde(default)]
1351    headers: HashMap<String, String>,
1352}
1353
1354#[derive(Debug, Deserialize)]
1355struct HttpFixture {
1356    expected_response: HttpExpectedResponse,
1357}
1358
1359#[derive(Debug, Deserialize)]
1360struct Fixture {
1361    id: String,
1362    #[serde(default)]
1363    mock_response: Option<MockResponse>,
1364    #[serde(default)]
1365    http: Option<HttpFixture>,
1366}
1367
1368impl Fixture {
1369    /// Bridge both schemas into a unified MockResponse.
1370    fn as_mock_response(&self) -> Option<MockResponse> {
1371        if let Some(mock) = &self.mock_response {
1372            return Some(MockResponse {
1373                status: mock.status,
1374                body: mock.body.clone(),
1375                stream_chunks: mock.stream_chunks.clone(),
1376                headers: mock.headers.clone(),
1377            });
1378        }
1379        if let Some(http) = &self.http {
1380            return Some(MockResponse {
1381                status: http.expected_response.status_code,
1382                body: http.expected_response.body.clone(),
1383                stream_chunks: None,
1384                headers: http.expected_response.headers.clone(),
1385            });
1386        }
1387        None
1388    }
1389}
1390
1391// ---------------------------------------------------------------------------
1392// Route table
1393// ---------------------------------------------------------------------------
1394
1395#[derive(Clone, Debug)]
1396struct MockRoute {
1397    status: u16,
1398    body: String,
1399    stream_chunks: Vec<String>,
1400    headers: Vec<(String, String)>,
1401}
1402
1403type RouteTable = Arc<HashMap<String, MockRoute>>;
1404
1405// ---------------------------------------------------------------------------
1406// Axum handler
1407// ---------------------------------------------------------------------------
1408
1409async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1410    let path = req.uri().path().to_owned();
1411
1412    // Try exact match first
1413    if let Some(route) = routes.get(&path) {
1414        return serve_route(route);
1415    }
1416
1417    // Try prefix match: find a route that is a prefix of the request path
1418    // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1419    for (route_path, route) in routes.iter() {
1420        if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1421            return serve_route(route);
1422        }
1423    }
1424
1425    Response::builder()
1426        .status(StatusCode::NOT_FOUND)
1427        .body(Body::from(format!("No mock route for {path}")))
1428        .unwrap()
1429        .into_response()
1430}
1431
1432fn serve_route(route: &MockRoute) -> Response {
1433    let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1434
1435    if !route.stream_chunks.is_empty() {
1436        let mut sse = String::new();
1437        for chunk in &route.stream_chunks {
1438            sse.push_str("data: ");
1439            sse.push_str(chunk);
1440            sse.push_str("\n\n");
1441        }
1442        sse.push_str("data: [DONE]\n\n");
1443
1444        let mut builder = Response::builder()
1445            .status(status)
1446            .header("content-type", "text/event-stream")
1447            .header("cache-control", "no-cache");
1448        for (name, value) in &route.headers {
1449            builder = builder.header(name, value);
1450        }
1451        return builder.body(Body::from(sse)).unwrap().into_response();
1452    }
1453
1454    // Only set the default content-type if the fixture does not override it.
1455    let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
1456    let mut builder = Response::builder().status(status);
1457    if !has_content_type {
1458        builder = builder.header("content-type", "application/json");
1459    }
1460    for (name, value) in &route.headers {
1461        // Skip content-encoding headers — the mock server returns uncompressed bodies.
1462        // Sending a content-encoding without actually encoding the body would cause
1463        // clients to fail decompression.
1464        if name.to_lowercase() == "content-encoding" {
1465            continue;
1466        }
1467        builder = builder.header(name, value);
1468    }
1469    builder.body(Body::from(route.body.clone())).unwrap().into_response()
1470}
1471
1472// ---------------------------------------------------------------------------
1473// Fixture loading
1474// ---------------------------------------------------------------------------
1475
1476fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1477    let mut routes = HashMap::new();
1478    load_routes_recursive(fixtures_dir, &mut routes);
1479    routes
1480}
1481
1482fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1483    let entries = match std::fs::read_dir(dir) {
1484        Ok(e) => e,
1485        Err(err) => {
1486            eprintln!("warning: cannot read directory {}: {err}", dir.display());
1487            return;
1488        }
1489    };
1490
1491    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1492    paths.sort();
1493
1494    for path in paths {
1495        if path.is_dir() {
1496            load_routes_recursive(&path, routes);
1497        } else if path.extension().is_some_and(|ext| ext == "json") {
1498            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1499            if filename == "schema.json" || filename.starts_with('_') {
1500                continue;
1501            }
1502            let content = match std::fs::read_to_string(&path) {
1503                Ok(c) => c,
1504                Err(err) => {
1505                    eprintln!("warning: cannot read {}: {err}", path.display());
1506                    continue;
1507                }
1508            };
1509            let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1510                match serde_json::from_str(&content) {
1511                    Ok(v) => v,
1512                    Err(err) => {
1513                        eprintln!("warning: cannot parse {}: {err}", path.display());
1514                        continue;
1515                    }
1516                }
1517            } else {
1518                match serde_json::from_str::<Fixture>(&content) {
1519                    Ok(f) => vec![f],
1520                    Err(err) => {
1521                        eprintln!("warning: cannot parse {}: {err}", path.display());
1522                        continue;
1523                    }
1524                }
1525            };
1526
1527            for fixture in fixtures {
1528                if let Some(mock) = fixture.as_mock_response() {
1529                    let route_path = format!("/fixtures/{}", fixture.id);
1530                    let body = mock
1531                        .body
1532                        .as_ref()
1533                        .map(|b| serde_json::to_string(b).unwrap_or_default())
1534                        .unwrap_or_default();
1535                    let stream_chunks = mock
1536                        .stream_chunks
1537                        .unwrap_or_default()
1538                        .into_iter()
1539                        .map(|c| match c {
1540                            serde_json::Value::String(s) => s,
1541                            other => serde_json::to_string(&other).unwrap_or_default(),
1542                        })
1543                        .collect();
1544                    let mut headers: Vec<(String, String)> =
1545                        mock.headers.into_iter().collect();
1546                    headers.sort_by(|a, b| a.0.cmp(&b.0));
1547                    routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
1548                }
1549            }
1550        }
1551    }
1552}
1553
1554// ---------------------------------------------------------------------------
1555// Entry point
1556// ---------------------------------------------------------------------------
1557
1558#[tokio::main]
1559async fn main() {
1560    let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1561    let fixtures_dir = Path::new(&fixtures_dir_arg);
1562
1563    let routes = load_routes(fixtures_dir);
1564    eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1565
1566    let route_table: RouteTable = Arc::new(routes);
1567    let app = Router::new().fallback(handle_request).with_state(route_table);
1568
1569    let listener = TcpListener::bind("127.0.0.1:0")
1570        .await
1571        .expect("mock-server: failed to bind port");
1572    let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1573
1574    // Print the URL so the parent process can read it.
1575    println!("MOCK_SERVER_URL=http://{addr}");
1576    // Flush stdout explicitly so the parent does not block waiting.
1577    use std::io::Write;
1578    std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1579
1580    // Spawn the server in the background.
1581    tokio::spawn(async move {
1582        axum::serve(listener, app).await.expect("mock-server: server error");
1583    });
1584
1585    // Block until stdin is closed — the parent process controls lifetime.
1586    let stdin = io::stdin();
1587    let mut lines = stdin.lock().lines();
1588    while lines.next().is_some() {}
1589}
1590"#
1591}
1592
1593// ---------------------------------------------------------------------------
1594// Assertion rendering
1595// ---------------------------------------------------------------------------
1596
1597#[allow(clippy::too_many_arguments)]
1598fn render_assertion(
1599    out: &mut String,
1600    assertion: &Assertion,
1601    result_var: &str,
1602    module: &str,
1603    dep_name: &str,
1604    is_error_context: bool,
1605    unwrapped_fields: &[(String, String)], // (fixture_field, local_var)
1606    field_resolver: &FieldResolver,
1607    result_is_tree: bool,
1608    result_is_simple: bool,
1609    result_is_vec: bool,
1610    result_is_option: bool,
1611) {
1612    // Vec<T> result: iterate per-element so each assertion checks every element.
1613    // Field-path assertions become `for r in &{result} { <assert using r> }`.
1614    // Length-style assertions on the Vec itself (no field path) operate on the
1615    // Vec directly.
1616    let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
1617    if result_is_vec && has_field && !is_error_context {
1618        let _ = writeln!(out, "    for r in &{result_var} {{");
1619        render_assertion(
1620            out,
1621            assertion,
1622            "r",
1623            module,
1624            dep_name,
1625            is_error_context,
1626            unwrapped_fields,
1627            field_resolver,
1628            result_is_tree,
1629            result_is_simple,
1630            false, // already inside loop
1631            result_is_option,
1632        );
1633        let _ = writeln!(out, "    }}");
1634        return;
1635    }
1636    // Option<T> result: map `is_empty`/`not_empty` to `is_none()`/`is_some()`,
1637    // and unwrap the inner value before any other assertion runs.
1638    if result_is_option && !is_error_context {
1639        let assertion_type = assertion.assertion_type.as_str();
1640        if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
1641            let check = if assertion_type == "is_empty" {
1642                "is_none"
1643            } else {
1644                "is_some"
1645            };
1646            let _ = writeln!(
1647                out,
1648                "    assert!({result_var}.{check}(), \"expected Option to be {check}\");"
1649            );
1650            return;
1651        }
1652        // For any other assertion shape, unwrap the Option and recurse with a
1653        // bare reference variable so the rest of the renderer treats the inner
1654        // value as the result.
1655        let _ = writeln!(
1656            out,
1657            "    let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
1658        );
1659        render_assertion(
1660            out,
1661            assertion,
1662            "r",
1663            module,
1664            dep_name,
1665            is_error_context,
1666            unwrapped_fields,
1667            field_resolver,
1668            result_is_tree,
1669            result_is_simple,
1670            result_is_vec,
1671            false, // already unwrapped
1672        );
1673        return;
1674    }
1675    let _ = dep_name;
1676    // Handle synthetic fields like chunks_have_content (derived assertions)
1677    if let Some(f) = &assertion.field {
1678        if f == "chunks_have_content" {
1679            match assertion.assertion_type.as_str() {
1680                "is_true" => {
1681                    let _ = writeln!(
1682                        out,
1683                        "    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\");"
1684                    );
1685                }
1686                "is_false" => {
1687                    let _ = writeln!(
1688                        out,
1689                        "    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\");"
1690                    );
1691                }
1692                _ => {
1693                    let _ = writeln!(
1694                        out,
1695                        "    // unsupported assertion type on synthetic field chunks_have_content"
1696                    );
1697                }
1698            }
1699            return;
1700        }
1701    }
1702
1703    // Skip assertions on fields that don't exist on the result type.
1704    if let Some(f) = &assertion.field {
1705        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1706            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
1707            return;
1708        }
1709    }
1710
1711    // Determine field access expression:
1712    // 1. If the field was unwrapped to a local var, use that local var name.
1713    // 2. When result_is_simple, the function returns a plain type (String etc.) — use result_var.
1714    // 3. When the field path is exactly the result var name (sentinel: `field: "result"`),
1715    //    refer to the result variable directly to avoid emitting `result.result`.
1716    // 4. When the result is a Tree, map pseudo-field names to correct Rust expressions.
1717    // 5. Otherwise, use the field resolver to generate the accessor.
1718    let field_access = match &assertion.field {
1719        Some(f) if !f.is_empty() => {
1720            if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
1721                local_var.clone()
1722            } else if result_is_simple {
1723                // Plain return type (String, Vec<T>, etc.) has no struct fields.
1724                // Use the result variable directly so assertions operate on the value itself.
1725                result_var.to_string()
1726            } else if f == result_var {
1727                // Sentinel: fixture uses `field: "result"` (or matches the result variable name)
1728                // to refer to the whole return value, not a struct field named "result".
1729                result_var.to_string()
1730            } else if result_is_tree {
1731                // Tree is an opaque type — its "fields" are accessed via root_node() or
1732                // free functions. Map known pseudo-field names to correct Rust expressions.
1733                tree_field_access_expr(f, result_var, module)
1734            } else {
1735                field_resolver.accessor(f, "rust", result_var)
1736            }
1737        }
1738        _ => result_var.to_string(),
1739    };
1740
1741    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
1742    let is_unwrapped = assertion
1743        .field
1744        .as_ref()
1745        .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
1746
1747    match assertion.assertion_type.as_str() {
1748        "error" => {
1749            let _ = writeln!(out, "    assert!({result_var}.is_err(), \"expected call to fail\");");
1750            if let Some(serde_json::Value::String(msg)) = &assertion.value {
1751                let escaped = escape_rust(msg);
1752                let _ = writeln!(
1753                    out,
1754                    "    assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
1755                );
1756            }
1757        }
1758        "not_error" => {
1759            // Handled at call site; nothing extra needed here.
1760        }
1761        "equals" => {
1762            if let Some(val) = &assertion.value {
1763                let expected = value_to_rust_string(val);
1764                if is_error_context {
1765                    return;
1766                }
1767                // For string equality, trim trailing whitespace to handle trailing newlines
1768                // from the converter.
1769                if val.is_string() {
1770                    // When the field is Optional<String> and was NOT pre-unwrapped to a local
1771                    // var (e.g. inside a result_is_vec iteration where the call-site unwrap
1772                    // pass is skipped), emit `.as_deref().unwrap_or("").trim()` so the
1773                    // expression is `&str` rather than `Option<String>`.
1774                    let is_opt_str_not_unwrapped = assertion.field.as_ref().is_some_and(|f| {
1775                        let resolved = field_resolver.resolve(f);
1776                        let is_opt = field_resolver.is_optional(resolved);
1777                        let is_arr = field_resolver.is_array(resolved);
1778                        is_opt && !is_arr && !is_unwrapped
1779                    });
1780                    let field_expr = if is_opt_str_not_unwrapped {
1781                        format!("{field_access}.as_deref().unwrap_or(\"\").trim()")
1782                    } else {
1783                        format!("{field_access}.trim()")
1784                    };
1785                    let _ = writeln!(
1786                        out,
1787                        "    assert_eq!({field_expr}, {expected}, \"equals assertion failed\");"
1788                    );
1789                } else if val.is_boolean() {
1790                    // Use assert!/assert!(!...) for booleans — clippy prefers this over assert_eq!(_, true/false).
1791                    if val.as_bool() == Some(true) {
1792                        let _ = writeln!(out, "    assert!({field_access}, \"equals assertion failed\");");
1793                    } else {
1794                        let _ = writeln!(out, "    assert!(!{field_access}, \"equals assertion failed\");");
1795                    }
1796                } else {
1797                    // Wrap expected value in Some() for optional fields.
1798                    let is_opt = assertion.field.as_ref().is_some_and(|f| {
1799                        let resolved = field_resolver.resolve(f);
1800                        field_resolver.is_optional(resolved)
1801                    });
1802                    if is_opt
1803                        && !unwrapped_fields
1804                            .iter()
1805                            .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
1806                    {
1807                        let _ = writeln!(
1808                            out,
1809                            "    assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
1810                        );
1811                    } else {
1812                        let _ = writeln!(
1813                            out,
1814                            "    assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
1815                        );
1816                    }
1817                }
1818            }
1819        }
1820        "contains" => {
1821            if let Some(val) = &assertion.value {
1822                let expected = value_to_rust_string(val);
1823                let line = format!(
1824                    "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1825                );
1826                let _ = writeln!(out, "{line}");
1827            }
1828        }
1829        "contains_all" => {
1830            if let Some(values) = &assertion.values {
1831                for val in values {
1832                    let expected = value_to_rust_string(val);
1833                    let line = format!(
1834                        "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1835                    );
1836                    let _ = writeln!(out, "{line}");
1837                }
1838            }
1839        }
1840        "not_contains" => {
1841            if let Some(val) = &assertion.value {
1842                let expected = value_to_rust_string(val);
1843                let line = format!(
1844                    "    assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
1845                );
1846                let _ = writeln!(out, "{line}");
1847            }
1848        }
1849        "not_empty" => {
1850            if let Some(f) = &assertion.field {
1851                let resolved = field_resolver.resolve(f);
1852                let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
1853                let is_arr = field_resolver.is_array(resolved);
1854                if is_opt && is_arr {
1855                    // Option<Vec<T>>: must be Some AND inner non-empty.
1856                    let accessor = field_resolver.accessor(f, "rust", result_var);
1857                    let _ = writeln!(
1858                        out,
1859                        "    assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected {f} to be present and non-empty\");"
1860                    );
1861                } else if is_opt {
1862                    // Non-collection optional field (e.g., Option<Struct>): use is_some().
1863                    let accessor = field_resolver.accessor(f, "rust", result_var);
1864                    let _ = writeln!(
1865                        out,
1866                        "    assert!({accessor}.is_some(), \"expected {f} to be present\");"
1867                    );
1868                } else {
1869                    let _ = writeln!(
1870                        out,
1871                        "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
1872                    );
1873                }
1874            } else if result_is_option {
1875                // Bare result is Option<T>: not_empty == is_some().
1876                let _ = writeln!(
1877                    out,
1878                    "    assert!({field_access}.is_some(), \"expected non-empty value\");"
1879                );
1880            } else {
1881                // Bare result is a struct/string/collection — non-empty via is_empty().
1882                let _ = writeln!(
1883                    out,
1884                    "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
1885                );
1886            }
1887        }
1888        "is_empty" => {
1889            if let Some(f) = &assertion.field {
1890                let resolved = field_resolver.resolve(f);
1891                let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
1892                let is_arr = field_resolver.is_array(resolved);
1893                if is_opt && is_arr {
1894                    // Option<Vec<T>>: empty means None or empty vec.
1895                    let accessor = field_resolver.accessor(f, "rust", result_var);
1896                    let _ = writeln!(
1897                        out,
1898                        "    assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected {f} to be empty or absent\");"
1899                    );
1900                } else if is_opt {
1901                    let accessor = field_resolver.accessor(f, "rust", result_var);
1902                    let _ = writeln!(out, "    assert!({accessor}.is_none(), \"expected {f} to be absent\");");
1903                } else {
1904                    let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
1905                }
1906            } else {
1907                let _ = writeln!(out, "    assert!({field_access}.is_none(), \"expected empty value\");");
1908            }
1909        }
1910        "contains_any" => {
1911            if let Some(values) = &assertion.values {
1912                let checks: Vec<String> = values
1913                    .iter()
1914                    .map(|v| {
1915                        let expected = value_to_rust_string(v);
1916                        format!("{field_access}.contains({expected})")
1917                    })
1918                    .collect();
1919                let joined = checks.join(" || ");
1920                let _ = writeln!(
1921                    out,
1922                    "    assert!({joined}, \"expected to contain at least one of the specified values\");"
1923                );
1924            }
1925        }
1926        "greater_than" => {
1927            if let Some(val) = &assertion.value {
1928                // Skip comparisons with negative values against unsigned types (.len() etc.)
1929                if val.as_f64().is_some_and(|n| n < 0.0) {
1930                    let _ = writeln!(
1931                        out,
1932                        "    // skipped: greater_than with negative value is always true for unsigned types"
1933                    );
1934                } else if val.as_u64() == Some(0) {
1935                    // Clippy prefers !is_empty() over len() > 0
1936                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1937                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected > 0\");");
1938                } else {
1939                    let lit = numeric_literal(val);
1940                    let _ = writeln!(out, "    assert!({field_access} > {lit}, \"expected > {lit}\");");
1941                }
1942            }
1943        }
1944        "less_than" => {
1945            if let Some(val) = &assertion.value {
1946                let lit = numeric_literal(val);
1947                let _ = writeln!(out, "    assert!({field_access} < {lit}, \"expected < {lit}\");");
1948            }
1949        }
1950        "greater_than_or_equal" => {
1951            if let Some(val) = &assertion.value {
1952                let lit = numeric_literal(val);
1953                // Check whether this field is optional but not an array — e.g. Option<usize>.
1954                // Directly comparing Option<usize> >= N is a type error; wrap with unwrap_or(0).
1955                let is_opt_numeric = assertion.field.as_ref().is_some_and(|f| {
1956                    let resolved = field_resolver.resolve(f);
1957                    let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
1958                    let is_arr = field_resolver.is_array(resolved);
1959                    is_opt && !is_arr
1960                });
1961                if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
1962                    // Clippy prefers !is_empty() over len() >= 1 for collections.
1963                    // Only apply when the expression is already a `.len()` call so we
1964                    // don't mistakenly call `.is_empty()` on numeric (usize) fields.
1965                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1966                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= 1\");");
1967                } else if is_opt_numeric {
1968                    // Option<usize> / Option<u64>: unwrap to 0 before comparing.
1969                    let _ = writeln!(
1970                        out,
1971                        "    assert!({field_access}.unwrap_or(0) >= {lit}, \"expected >= {lit}\");"
1972                    );
1973                } else {
1974                    let _ = writeln!(out, "    assert!({field_access} >= {lit}, \"expected >= {lit}\");");
1975                }
1976            }
1977        }
1978        "less_than_or_equal" => {
1979            if let Some(val) = &assertion.value {
1980                let lit = numeric_literal(val);
1981                let _ = writeln!(out, "    assert!({field_access} <= {lit}, \"expected <= {lit}\");");
1982            }
1983        }
1984        "starts_with" => {
1985            if let Some(val) = &assertion.value {
1986                let expected = value_to_rust_string(val);
1987                let _ = writeln!(
1988                    out,
1989                    "    assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
1990                );
1991            }
1992        }
1993        "ends_with" => {
1994            if let Some(val) = &assertion.value {
1995                let expected = value_to_rust_string(val);
1996                let _ = writeln!(
1997                    out,
1998                    "    assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
1999                );
2000            }
2001        }
2002        "min_length" => {
2003            if let Some(val) = &assertion.value {
2004                if let Some(n) = val.as_u64() {
2005                    let _ = writeln!(
2006                        out,
2007                        "    assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
2008                    );
2009                }
2010            }
2011        }
2012        "max_length" => {
2013            if let Some(val) = &assertion.value {
2014                if let Some(n) = val.as_u64() {
2015                    let _ = writeln!(
2016                        out,
2017                        "    assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
2018                    );
2019                }
2020            }
2021        }
2022        "count_min" => {
2023            if let Some(val) = &assertion.value {
2024                if let Some(n) = val.as_u64() {
2025                    let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2026                        let resolved = field_resolver.resolve(f);
2027                        let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2028                        let is_arr = field_resolver.is_array(resolved);
2029                        is_opt && is_arr
2030                    });
2031                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2032                    if opt_arr_field {
2033                        // Option<Vec<T>>: must be Some AND inner len >= n.
2034                        if n <= 1 {
2035                            let _ = writeln!(
2036                                out,
2037                                "    assert!({base}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2038                            );
2039                        } else {
2040                            let _ = writeln!(
2041                                out,
2042                                "    assert!({base}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} elements\");"
2043                            );
2044                        }
2045                    } else if n <= 1 {
2046                        let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= {n}\");");
2047                    } else {
2048                        let _ = writeln!(
2049                            out,
2050                            "    assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
2051                        );
2052                    }
2053                }
2054            }
2055        }
2056        "count_equals" => {
2057            if let Some(val) = &assertion.value {
2058                if let Some(n) = val.as_u64() {
2059                    let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2060                        let resolved = field_resolver.resolve(f);
2061                        let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2062                        let is_arr = field_resolver.is_array(resolved);
2063                        is_opt && is_arr
2064                    });
2065                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2066                    if opt_arr_field {
2067                        let _ = writeln!(
2068                            out,
2069                            "    assert!({base}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} elements\");"
2070                        );
2071                    } else {
2072                        let _ = writeln!(
2073                            out,
2074                            "    assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
2075                        );
2076                    }
2077                }
2078            }
2079        }
2080        "is_true" => {
2081            let _ = writeln!(out, "    assert!({field_access}, \"expected true\");");
2082        }
2083        "is_false" => {
2084            let _ = writeln!(out, "    assert!(!{field_access}, \"expected false\");");
2085        }
2086        "method_result" => {
2087            if let Some(method_name) = &assertion.method {
2088                // Build the call expression. When the result is a tree-sitter Tree (an opaque
2089                // type), methods like `root_child_count` do not exist on `Tree` directly —
2090                // they are free functions in the crate or are accessed via `root_node()`.
2091                let call_expr = if result_is_tree {
2092                    build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
2093                } else if let Some(args) = &assertion.args {
2094                    let arg_lit = json_to_rust_literal(args, "");
2095                    format!("{field_access}.{method_name}({arg_lit})")
2096                } else {
2097                    format!("{field_access}.{method_name}()")
2098                };
2099
2100                // Determine whether the call expression returns a numeric type so we can
2101                // choose the right comparison strategy for `greater_than_or_equal`.
2102                let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
2103
2104                let check = assertion.check.as_deref().unwrap_or("is_true");
2105                match check {
2106                    "equals" => {
2107                        if let Some(val) = &assertion.value {
2108                            if val.is_boolean() {
2109                                if val.as_bool() == Some(true) {
2110                                    let _ = writeln!(
2111                                        out,
2112                                        "    assert!({call_expr}, \"method_result equals assertion failed\");"
2113                                    );
2114                                } else {
2115                                    let _ = writeln!(
2116                                        out,
2117                                        "    assert!(!{call_expr}, \"method_result equals assertion failed\");"
2118                                    );
2119                                }
2120                            } else {
2121                                let expected = value_to_rust_string(val);
2122                                let _ = writeln!(
2123                                    out,
2124                                    "    assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
2125                                );
2126                            }
2127                        }
2128                    }
2129                    "is_true" => {
2130                        let _ = writeln!(
2131                            out,
2132                            "    assert!({call_expr}, \"method_result is_true assertion failed\");"
2133                        );
2134                    }
2135                    "is_false" => {
2136                        let _ = writeln!(
2137                            out,
2138                            "    assert!(!{call_expr}, \"method_result is_false assertion failed\");"
2139                        );
2140                    }
2141                    "greater_than_or_equal" => {
2142                        if let Some(val) = &assertion.value {
2143                            let lit = numeric_literal(val);
2144                            if returns_numeric {
2145                                // Numeric return (e.g., child_count()) — always use >= comparison.
2146                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2147                            } else if val.as_u64() == Some(1) {
2148                                // Clippy prefers !is_empty() over len() >= 1 for collections.
2149                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
2150                            } else {
2151                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2152                            }
2153                        }
2154                    }
2155                    "count_min" => {
2156                        if let Some(val) = &assertion.value {
2157                            let n = val.as_u64().unwrap_or(0);
2158                            if n <= 1 {
2159                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
2160                            } else {
2161                                let _ = writeln!(
2162                                    out,
2163                                    "    assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
2164                                );
2165                            }
2166                        }
2167                    }
2168                    "is_error" => {
2169                        // For is_error we need the raw Result without .unwrap().
2170                        let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
2171                        let _ = writeln!(
2172                            out,
2173                            "    assert!({raw_call}.is_err(), \"expected method to return error\");"
2174                        );
2175                    }
2176                    "contains" => {
2177                        if let Some(val) = &assertion.value {
2178                            let expected = value_to_rust_string(val);
2179                            let _ = writeln!(
2180                                out,
2181                                "    assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
2182                            );
2183                        }
2184                    }
2185                    "not_empty" => {
2186                        let _ = writeln!(
2187                            out,
2188                            "    assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
2189                        );
2190                    }
2191                    "is_empty" => {
2192                        let _ = writeln!(out, "    assert!({call_expr}.is_empty(), \"expected empty result\");");
2193                    }
2194                    other_check => {
2195                        panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
2196                    }
2197                }
2198            } else {
2199                panic!("Rust e2e generator: method_result assertion missing 'method' field");
2200            }
2201        }
2202        other => {
2203            panic!("Rust e2e generator: unsupported assertion type: {other}");
2204        }
2205    }
2206}
2207
2208/// Translate a fixture pseudo-field name on a `tree_sitter::Tree` into the
2209/// correct Rust accessor expression.
2210///
2211/// When an assertion uses `field: "root_child_count"` on a tree result, the
2212/// field resolver would naively emit `tree.root_child_count` — which is invalid
2213/// because `Tree` is an opaque type with no such field.  This function maps the
2214/// pseudo-field to the correct Rust expression instead.
2215fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
2216    match field {
2217        "root_child_count" => format!("{result_var}.root_node().child_count()"),
2218        "root_node_type" => format!("{result_var}.root_node().kind()"),
2219        "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
2220        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
2221        "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
2222        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
2223        // Unknown pseudo-field: fall back to direct field access (will likely fail to compile,
2224        // but gives the developer a useful error pointing to the fixture).
2225        other => format!("{result_var}.{other}"),
2226    }
2227}
2228
2229/// Build a Rust call expression for a logical "method" on a `tree_sitter::Tree`.
2230///
2231/// `Tree` is an opaque type — it does not expose methods like `root_child_count`.
2232/// Instead, these are either free functions in the crate or are accessed via
2233/// `tree.root_node().<method>()`. This function translates the fixture-level
2234/// method name into the correct Rust expression.
2235fn build_tree_call_expr(
2236    field_access: &str,
2237    method_name: &str,
2238    args: Option<&serde_json::Value>,
2239    module: &str,
2240) -> String {
2241    match method_name {
2242        "root_child_count" => format!("{field_access}.root_node().child_count()"),
2243        "root_node_type" => format!("{field_access}.root_node().kind()"),
2244        "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
2245        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
2246        "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
2247        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
2248        "contains_node_type" => {
2249            let node_type = args
2250                .and_then(|a| a.get("node_type"))
2251                .and_then(|v| v.as_str())
2252                .unwrap_or("");
2253            format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
2254        }
2255        "find_nodes_by_type" => {
2256            let node_type = args
2257                .and_then(|a| a.get("node_type"))
2258                .and_then(|v| v.as_str())
2259                .unwrap_or("");
2260            format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
2261        }
2262        "run_query" => {
2263            let query_source = args
2264                .and_then(|a| a.get("query_source"))
2265                .and_then(|v| v.as_str())
2266                .unwrap_or("");
2267            let language = args
2268                .and_then(|a| a.get("language"))
2269                .and_then(|v| v.as_str())
2270                .unwrap_or("");
2271            // Use a raw string for the query to avoid escaping issues.
2272            // run_query returns Result — unwrap it for assertion access.
2273            format!(
2274                "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
2275            )
2276        }
2277        // Fallback: try as a plain method call.
2278        _ => {
2279            if let Some(args) = args {
2280                let arg_lit = json_to_rust_literal(args, "");
2281                format!("{field_access}.{method_name}({arg_lit})")
2282            } else {
2283                format!("{field_access}.{method_name}()")
2284            }
2285        }
2286    }
2287}
2288
2289/// Returns `true` when the tree method name produces a numeric result (usize/u64),
2290/// meaning `>= N` comparisons should use direct numeric comparison rather than
2291/// `.is_empty()` (which only works for collections).
2292fn is_tree_numeric_method(method_name: &str) -> bool {
2293    matches!(
2294        method_name,
2295        "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
2296    )
2297}
2298
2299/// Convert a JSON numeric value to a Rust literal suitable for comparisons.
2300///
2301/// Whole numbers (no fractional part) are emitted as bare integer literals so
2302/// they are compatible with `usize`, `u64`, etc. (e.g., `.len()` results).
2303/// Numbers with a fractional component get the `_f64` suffix.
2304fn numeric_literal(value: &serde_json::Value) -> String {
2305    if let Some(n) = value.as_f64() {
2306        if n.fract() == 0.0 {
2307            // Whole number — emit without a type suffix so Rust can infer the
2308            // correct integer type from context (usize, u64, i64, …).
2309            return format!("{}", n as i64);
2310        }
2311        return format!("{n}_f64");
2312    }
2313    // Fallback: use the raw JSON representation.
2314    value.to_string()
2315}
2316
2317fn value_to_rust_string(value: &serde_json::Value) -> String {
2318    match value {
2319        serde_json::Value::String(s) => rust_raw_string(s),
2320        serde_json::Value::Bool(b) => format!("{b}"),
2321        serde_json::Value::Number(n) => n.to_string(),
2322        other => {
2323            let s = other.to_string();
2324            format!("\"{s}\"")
2325        }
2326    }
2327}
2328
2329// ---------------------------------------------------------------------------
2330// Visitor generation
2331// ---------------------------------------------------------------------------
2332
2333/// Resolve the visitor trait name based on module.
2334fn resolve_visitor_trait(module: &str) -> String {
2335    // For html_to_markdown modules, use HtmlVisitor
2336    if module.contains("html_to_markdown") {
2337        "HtmlVisitor".to_string()
2338    } else {
2339        // Default fallback for other modules
2340        "Visitor".to_string()
2341    }
2342}
2343
2344/// Emit a Rust visitor method for a callback action.
2345///
2346/// The parameter type list mirrors the `HtmlVisitor` trait in
2347/// `kreuzberg-dev/html-to-markdown`. Param names are bound to `_` because the
2348/// generated visitor body never references them — the body always returns a
2349/// fixed `VisitResult` variant — so we'd otherwise hit `unused_variables`
2350/// warnings that fail prek's `cargo clippy -D warnings` hook.
2351fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
2352    // Each entry: parameters typed exactly as `HtmlVisitor` expects them,
2353    // bound to `_` patterns so the generated body needn't introduce unused
2354    // bindings. Receiver is `&mut self` to match the trait.
2355    let params = match method_name {
2356        "visit_link" => "_: &NodeContext, _: &str, _: &str, _: &str",
2357        "visit_image" => "_: &NodeContext, _: &str, _: &str, _: &str",
2358        "visit_heading" => "_: &NodeContext, _: u8, _: &str, _: Option<&str>",
2359        "visit_code_block" => "_: &NodeContext, _: Option<&str>, _: &str",
2360        "visit_code_inline"
2361        | "visit_strong"
2362        | "visit_emphasis"
2363        | "visit_strikethrough"
2364        | "visit_underline"
2365        | "visit_subscript"
2366        | "visit_superscript"
2367        | "visit_mark"
2368        | "visit_button"
2369        | "visit_summary"
2370        | "visit_figcaption"
2371        | "visit_definition_term"
2372        | "visit_definition_description" => "_: &NodeContext, _: &str",
2373        "visit_text" => "_: &NodeContext, _: &str",
2374        "visit_list_item" => "_: &NodeContext, _: bool, _: &str, _: &str",
2375        "visit_blockquote" => "_: &NodeContext, _: &str, _: u32",
2376        "visit_table_row" => "_: &NodeContext, _: &[String], _: bool",
2377        "visit_custom_element" => "_: &NodeContext, _: &str, _: &str",
2378        "visit_form" => "_: &NodeContext, _: &str, _: &str",
2379        "visit_input" => "_: &NodeContext, _: &str, _: &str, _: &str",
2380        "visit_audio" | "visit_video" | "visit_iframe" => "_: &NodeContext, _: &str",
2381        "visit_details" => "_: &NodeContext, _: bool",
2382        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2383            "_: &NodeContext, _: &str"
2384        }
2385        "visit_list_start" => "_: &NodeContext, _: bool",
2386        "visit_list_end" => "_: &NodeContext, _: bool, _: &str",
2387        _ => "_: &NodeContext",
2388    };
2389
2390    let _ = writeln!(out, "        fn {method_name}(&mut self, {params}) -> VisitResult {{");
2391    match action {
2392        CallbackAction::Skip => {
2393            let _ = writeln!(out, "            VisitResult::Skip");
2394        }
2395        CallbackAction::Continue => {
2396            let _ = writeln!(out, "            VisitResult::Continue");
2397        }
2398        CallbackAction::PreserveHtml => {
2399            let _ = writeln!(out, "            VisitResult::PreserveHtml");
2400        }
2401        CallbackAction::Custom { output } => {
2402            let escaped = escape_rust(output);
2403            let _ = writeln!(out, "            VisitResult::Custom(\"{escaped}\".to_string())");
2404        }
2405        CallbackAction::CustomTemplate { template } => {
2406            let escaped = escape_rust(template);
2407            let _ = writeln!(out, "            VisitResult::Custom(format!(\"{escaped}\"))");
2408        }
2409    }
2410    let _ = writeln!(out, "        }}");
2411}