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