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::config::E2eConfig;
7use crate::escape::{escape_rust, rust_raw_string, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16/// Rust e2e test code generator.
17pub struct RustE2eCodegen;
18
19impl super::E2eCodegen for RustE2eCodegen {
20    fn generate(
21        &self,
22        groups: &[FixtureGroup],
23        e2e_config: &E2eConfig,
24        alef_config: &AlefConfig,
25    ) -> Result<Vec<GeneratedFile>> {
26        let mut files = Vec::new();
27        let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
28
29        // Resolve crate name and path from config.
30        let crate_name = resolve_crate_name(e2e_config, alef_config);
31        let crate_path = resolve_crate_path(e2e_config, &crate_name);
32        let dep_name = crate_name.replace('-', "_");
33
34        // Cargo.toml
35        // Check if any call config (default or named) uses json_object/handle args (needs serde_json dep).
36        let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
37        let needs_serde_json = all_call_configs
38            .flat_map(|c| c.args.iter())
39            .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
40
41        // Check if any fixture in any group requires a mock HTTP server.
42        let needs_mock_server = groups
43            .iter()
44            .flat_map(|g| g.fixtures.iter())
45            .any(|f| !is_skipped(f, "rust") && f.needs_mock_server());
46
47        // Tokio is needed when any test is async (mock server or async call config).
48        let any_async_call = std::iter::once(&e2e_config.call)
49            .chain(e2e_config.calls.values())
50            .any(|c| c.r#async);
51        let needs_tokio = needs_mock_server || any_async_call;
52
53        let crate_version = resolve_crate_version(e2e_config);
54        files.push(GeneratedFile {
55            path: output_base.join("Cargo.toml"),
56            content: render_cargo_toml(
57                &crate_name,
58                &dep_name,
59                &crate_path,
60                needs_serde_json,
61                needs_mock_server,
62                needs_tokio,
63                e2e_config.dep_mode,
64                crate_version.as_deref(),
65                &alef_config.crate_config.features,
66            ),
67            generated_header: true,
68        });
69
70        // Generate mock_server.rs when at least one fixture uses mock_response.
71        if needs_mock_server {
72            files.push(GeneratedFile {
73                path: output_base.join("tests").join("mock_server.rs"),
74                content: render_mock_server_module(),
75                generated_header: true,
76            });
77            // Generate standalone mock-server binary for cross-language e2e suites.
78            files.push(GeneratedFile {
79                path: output_base.join("src").join("main.rs"),
80                content: render_mock_server_binary(),
81                generated_header: true,
82            });
83        }
84
85        // Per-category test files.
86        for group in groups {
87            let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
88
89            if fixtures.is_empty() {
90                continue;
91            }
92
93            let filename = format!("{}_test.rs", sanitize_filename(&group.category));
94            let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
95
96            files.push(GeneratedFile {
97                path: output_base.join("tests").join(filename),
98                content,
99                generated_header: true,
100            });
101        }
102
103        Ok(files)
104    }
105
106    fn language_name(&self) -> &'static str {
107        "rust"
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Config resolution helpers
113// ---------------------------------------------------------------------------
114
115fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
116    // Always use the Cargo package name (with hyphens) from alef.toml [crate].
117    // The `crate_name` override in [e2e.call.overrides.rust] is for the Rust
118    // import identifier, not the Cargo package name.
119    alef_config.crate_config.name.clone()
120}
121
122fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
123    e2e_config
124        .resolve_package("rust")
125        .and_then(|p| p.path.clone())
126        .unwrap_or_else(|| format!("../../crates/{crate_name}"))
127}
128
129fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
130    e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
131}
132
133fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
134    call_config
135        .overrides
136        .get("rust")
137        .and_then(|o| o.function.clone())
138        .unwrap_or_else(|| call_config.function.clone())
139}
140
141fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
142    resolve_module_for_call(&e2e_config.call, dep_name)
143}
144
145fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
146    // For Rust, the module name is the crate identifier (underscores).
147    // Priority: override.crate_name > override.module > dep_name
148    let overrides = call_config.overrides.get("rust");
149    overrides
150        .and_then(|o| o.crate_name.clone())
151        .or_else(|| overrides.and_then(|o| o.module.clone()))
152        .unwrap_or_else(|| dep_name.to_string())
153}
154
155fn is_skipped(fixture: &Fixture, language: &str) -> bool {
156    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
157}
158
159// ---------------------------------------------------------------------------
160// Rendering
161// ---------------------------------------------------------------------------
162
163#[allow(clippy::too_many_arguments)]
164fn render_cargo_toml(
165    crate_name: &str,
166    dep_name: &str,
167    crate_path: &str,
168    needs_serde_json: bool,
169    needs_mock_server: bool,
170    needs_tokio: bool,
171    dep_mode: crate::config::DependencyMode,
172    version: Option<&str>,
173    features: &[String],
174) -> String {
175    let e2e_name = format!("{dep_name}-e2e-rust");
176    let features_str = if features.is_empty() {
177        String::new()
178    } else {
179        let feat_list: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
180        format!(", default-features = false, features = {:?}", feat_list)
181    };
182    let dep_spec = match dep_mode {
183        crate::config::DependencyMode::Registry => {
184            let ver = version.unwrap_or("0.1.0");
185            if crate_name != dep_name {
186                format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
187            } else if features.is_empty() {
188                format!("{dep_name} = \"{ver}\"")
189            } else {
190                format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
191            }
192        }
193        crate::config::DependencyMode::Local => {
194            if crate_name != dep_name {
195                format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
196            } else if features.is_empty() {
197                format!("{dep_name} = {{ path = \"{crate_path}\" }}")
198            } else {
199                format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
200            }
201        }
202    };
203    let serde_line = if needs_serde_json { "\nserde_json = \"1\"" } else { "" };
204    // In registry mode the generated Cargo.toml is a standalone project, so add
205    // an empty [workspace] table to opt out of parent workspace discovery.
206    // In local mode the e2e crate is typically listed as a workspace member of
207    // the parent project, so adding [workspace] would create a conflicting
208    // second workspace root — omit it.
209    // Always add [workspace] — both registry and local e2e crates are standalone
210    // projects that must not inherit the parent workspace.
211    let workspace_section = "\n[workspace]\n";
212    // Mock server requires axum (HTTP router) and tokio-stream (SSE streaming).
213    // The standalone binary additionally needs serde (derive) and walkdir.
214    let mock_lines = if needs_mock_server {
215        "\naxum = \"0.8\"\ntokio-stream = \"0.1\"\nserde = { version = \"1\", features = [\"derive\"] }\nwalkdir = \"2\""
216    } else {
217        ""
218    };
219    let mut machete_ignored: Vec<&str> = Vec::new();
220    if needs_serde_json {
221        machete_ignored.push("\"serde_json\"");
222    }
223    if needs_mock_server {
224        machete_ignored.push("\"axum\"");
225        machete_ignored.push("\"tokio-stream\"");
226        machete_ignored.push("\"serde\"");
227        machete_ignored.push("\"walkdir\"");
228    }
229    let machete_section = if machete_ignored.is_empty() {
230        String::new()
231    } else {
232        format!(
233            "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
234            machete_ignored.join(", ")
235        )
236    };
237    let tokio_line = if needs_tokio {
238        "\ntokio = { version = \"1\", features = [\"full\"] }"
239    } else {
240        ""
241    };
242    let bin_section = if needs_mock_server {
243        "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
244    } else {
245        ""
246    };
247    format!(
248        r#"# This file is auto-generated by alef. DO NOT EDIT.
249{workspace_section}
250[package]
251name = "{e2e_name}"
252version = "0.1.0"
253edition = "2021"
254license = "MIT"
255publish = false
256{bin_section}
257[dependencies]
258{dep_spec}{serde_line}{mock_lines}{tokio_line}
259{machete_section}"#
260    )
261}
262
263fn render_test_file(
264    category: &str,
265    fixtures: &[&Fixture],
266    e2e_config: &E2eConfig,
267    dep_name: &str,
268    needs_mock_server: bool,
269) -> String {
270    let mut out = String::new();
271    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
272    let _ = writeln!(out, "//! E2e tests for category: {category}");
273    let _ = writeln!(out);
274
275    let module = resolve_module(e2e_config, dep_name);
276    let field_resolver = FieldResolver::new(
277        &e2e_config.fields,
278        &e2e_config.fields_optional,
279        &e2e_config.result_fields,
280        &e2e_config.fields_array,
281    );
282
283    // Collect all unique (module, function) pairs needed across all fixtures in this file.
284    // Fixtures that name a specific call may use a different function (and module) than
285    // the default [e2e.call] config.
286    let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
287    for fixture in fixtures.iter() {
288        let call_config = e2e_config.resolve_call(fixture.call.as_deref());
289        let fn_name = resolve_function_name_for_call(call_config);
290        let mod_name = resolve_module_for_call(call_config, dep_name);
291        imported.insert((mod_name, fn_name));
292    }
293    // Emit use statements, grouping by module when possible.
294    let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
295    for (mod_name, fn_name) in &imported {
296        by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
297    }
298    for (mod_name, fns) in &by_module {
299        if fns.len() == 1 {
300            let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
301        } else {
302            let joined = fns.join(", ");
303            let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
304        }
305    }
306
307    // Import handle constructor functions and the config type they use.
308    let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
309    if has_handle_args {
310        let _ = writeln!(out, "use {module}::CrawlConfig;");
311    }
312    for arg in &e2e_config.call.args {
313        if arg.arg_type == "handle" {
314            use heck::ToSnakeCase;
315            let constructor_name = format!("create_{}", arg.name.to_snake_case());
316            let _ = writeln!(out, "use {module}::{constructor_name};");
317        }
318    }
319
320    // Import mock_server module when any fixture in this file uses mock_response.
321    let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.needs_mock_server());
322    if file_needs_mock {
323        let _ = writeln!(out, "mod mock_server;");
324        let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
325    }
326
327    let _ = writeln!(out);
328
329    for fixture in fixtures {
330        render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
331        let _ = writeln!(out);
332    }
333
334    if !out.ends_with('\n') {
335        out.push('\n');
336    }
337    out
338}
339
340fn render_test_function(
341    out: &mut String,
342    fixture: &Fixture,
343    e2e_config: &E2eConfig,
344    dep_name: &str,
345    field_resolver: &FieldResolver,
346) {
347    let fn_name = sanitize_ident(&fixture.id);
348    let description = &fixture.description;
349    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
350    let function_name = resolve_function_name_for_call(call_config);
351    let module = resolve_module_for_call(call_config, dep_name);
352    let result_var = &call_config.result_var;
353    let has_mock = fixture.needs_mock_server();
354
355    // Tests with a mock server are always async (Axum requires a Tokio runtime).
356    let is_async = call_config.r#async || has_mock;
357    if is_async {
358        let _ = writeln!(out, "#[tokio::test]");
359        let _ = writeln!(out, "async fn test_{fn_name}() {{");
360    } else {
361        let _ = writeln!(out, "#[test]");
362        let _ = writeln!(out, "fn test_{fn_name}() {{");
363    }
364    let _ = writeln!(out, "    // {description}");
365
366    // Emit mock server setup before building arguments so arg expressions can
367    // reference `mock_server.url` when needed.
368    if has_mock {
369        render_mock_server_setup(out, fixture, e2e_config);
370    }
371
372    // Check if any assertion is an error assertion.
373    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
374
375    // Emit input variable bindings from args config.
376    let mut arg_exprs: Vec<String> = Vec::new();
377    for arg in &call_config.args {
378        let value = resolve_field(&fixture.input, &arg.field);
379        let var_name = &arg.name;
380        let (bindings, expr) = render_rust_arg(
381            var_name,
382            value,
383            &arg.arg_type,
384            arg.optional,
385            &module,
386            &fixture.id,
387            if has_mock {
388                Some("mock_server.url.as_str()")
389            } else {
390                None
391            },
392        );
393        for binding in &bindings {
394            let _ = writeln!(out, "    {binding}");
395        }
396        arg_exprs.push(expr);
397    }
398
399    // Emit visitor if present in fixture.
400    if let Some(visitor_spec) = &fixture.visitor {
401        let _ = writeln!(out, "    struct _TestVisitor;");
402        let _ = writeln!(out, "    impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
403        for (method_name, action) in &visitor_spec.callbacks {
404            emit_rust_visitor_method(out, method_name, action);
405        }
406        let _ = writeln!(out, "    }}");
407        let _ = writeln!(
408            out,
409            "    let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
410        );
411        arg_exprs.push("Some(visitor)".to_string());
412    }
413
414    let args_str = arg_exprs.join(", ");
415
416    let await_suffix = if is_async { ".await" } else { "" };
417
418    let result_is_tree = call_config.result_var == "tree";
419
420    if has_error_assertion {
421        let _ = writeln!(out, "    let {result_var} = {function_name}({args_str}){await_suffix};");
422        // Render error assertions.
423        for assertion in &fixture.assertions {
424            render_assertion(
425                out,
426                assertion,
427                result_var,
428                &module,
429                dep_name,
430                true,
431                &[],
432                field_resolver,
433                result_is_tree,
434            );
435        }
436        let _ = writeln!(out, "}}");
437        return;
438    }
439
440    // Non-error path: unwrap the result.
441    let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
442
443    // Check if any assertion actually uses the result variable.
444    // If all assertions are skipped (field not on result type), use `_` to avoid
445    // Rust's "variable never used" warning.
446    let has_usable_assertion = fixture.assertions.iter().any(|a| {
447        if a.assertion_type == "not_error" || a.assertion_type == "error" {
448            return false;
449        }
450        match &a.field {
451            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
452            _ => true,
453        }
454    });
455
456    let result_binding = if has_usable_assertion {
457        result_var.to_string()
458    } else {
459        "_".to_string()
460    };
461
462    // Detect Option-returning functions: only skip unwrap when ALL assertions are
463    // pure emptiness/bool checks with NO field access (is_none/is_some on the result itself).
464    // If any assertion accesses a field (e.g. `html`), we need the inner value, so unwrap.
465    let has_field_access = fixture
466        .assertions
467        .iter()
468        .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
469    let only_emptiness_checks = !has_not_error
470        && !has_field_access
471        && fixture.assertions.iter().all(|a| {
472            matches!(
473                a.assertion_type.as_str(),
474                "is_empty" | "is_false" | "not_empty" | "is_true"
475            )
476        });
477
478    if only_emptiness_checks {
479        // Option-returning: don't unwrap, emit is_none/is_some checks directly
480        let _ = writeln!(
481            out,
482            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
483        );
484    } else if has_not_error || !fixture.assertions.is_empty() {
485        let _ = writeln!(
486            out,
487            "    let {result_binding} = {function_name}({args_str}){await_suffix}.expect(\"should succeed\");"
488        );
489    } else {
490        let _ = writeln!(
491            out,
492            "    let {result_binding} = {function_name}({args_str}){await_suffix};"
493        );
494    }
495
496    // Emit Option field unwrap bindings for any fields accessed in assertions.
497    // Use FieldResolver to handle optional fields, including nested/aliased paths.
498    let string_assertion_types = [
499        "equals",
500        "contains",
501        "contains_all",
502        "contains_any",
503        "not_contains",
504        "starts_with",
505        "ends_with",
506        "min_length",
507        "max_length",
508        "matches_regex",
509    ];
510    let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); // (fixture_field, local_var)
511    for assertion in &fixture.assertions {
512        if let Some(f) = &assertion.field {
513            if !f.is_empty()
514                && string_assertion_types.contains(&assertion.assertion_type.as_str())
515                && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
516            {
517                // Only unwrap optional string fields — numeric optionals (u64, usize)
518                // don't support .as_deref() and should be compared directly.
519                let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
520                if !is_string_assertion {
521                    continue;
522                }
523                if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
524                    let _ = writeln!(out, "    {binding}");
525                    unwrapped_fields.push((f.clone(), local_var));
526                }
527            }
528        }
529    }
530
531    // Render assertions.
532    for assertion in &fixture.assertions {
533        if assertion.assertion_type == "not_error" {
534            // Already handled by .expect() above.
535            continue;
536        }
537        render_assertion(
538            out,
539            assertion,
540            result_var,
541            &module,
542            dep_name,
543            false,
544            &unwrapped_fields,
545            field_resolver,
546            result_is_tree,
547        );
548    }
549
550    let _ = writeln!(out, "}}");
551}
552
553// ---------------------------------------------------------------------------
554// Argument rendering
555// ---------------------------------------------------------------------------
556
557fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
558    // Field paths in call config are "input.path", "input.config", etc.
559    // Since we already receive `fixture.input`, strip the leading "input." prefix.
560    let path = field_path.strip_prefix("input.").unwrap_or(field_path);
561    let mut current = input;
562    for part in path.split('.') {
563        current = current.get(part).unwrap_or(&serde_json::Value::Null);
564    }
565    current
566}
567
568fn render_rust_arg(
569    name: &str,
570    value: &serde_json::Value,
571    arg_type: &str,
572    optional: bool,
573    module: &str,
574    fixture_id: &str,
575    mock_base_url: Option<&str>,
576) -> (Vec<String>, String) {
577    if arg_type == "mock_url" {
578        let lines = vec![format!(
579            "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
580        )];
581        return (lines, format!("&{name}"));
582    }
583    // When the arg is a base_url and a mock server is running, use the mock server URL.
584    if arg_type == "base_url" {
585        if let Some(url_expr) = mock_base_url {
586            return (vec![], url_expr.to_string());
587        }
588        // No mock server: fall through to string handling below.
589    }
590    if arg_type == "handle" {
591        // Generate a create_engine (or equivalent) call and pass the config.
592        // If the fixture has input.config, serialize it as a json_object and pass it;
593        // otherwise pass None.
594        use heck::ToSnakeCase;
595        let constructor_name = format!("create_{}", name.to_snake_case());
596        let mut lines = Vec::new();
597        if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
598            lines.push(format!(
599                "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
600            ));
601        } else {
602            // Serialize the config JSON and deserialize at runtime.
603            let json_literal = serde_json::to_string(value).unwrap_or_default();
604            let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
605            lines.push(format!(
606                "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
607            ));
608            lines.push(format!(
609                "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
610            ));
611        }
612        return (lines, format!("&{name}"));
613    }
614    if arg_type == "json_object" {
615        return render_json_object_arg(name, value, optional, module);
616    }
617    if value.is_null() && !optional {
618        // Required arg with no fixture value: use a language-appropriate default.
619        let default_val = match arg_type {
620            "string" => "String::new()".to_string(),
621            "int" | "integer" => "0".to_string(),
622            "float" | "number" => "0.0_f64".to_string(),
623            "bool" | "boolean" => "false".to_string(),
624            _ => "Default::default()".to_string(),
625        };
626        // String args are passed by reference in Rust.
627        let expr = if arg_type == "string" {
628            format!("&{name}")
629        } else {
630            name.to_string()
631        };
632        return (vec![format!("let {name} = {default_val};")], expr);
633    }
634    let literal = json_to_rust_literal(value, arg_type);
635    // String args are passed by reference in Rust.
636    // Bytes args are strings passed as .as_bytes().
637    let pass_by_ref = arg_type == "string" || arg_type == "bytes";
638    let expr = |n: &str| {
639        if arg_type == "bytes" {
640            format!("{n}.as_bytes()")
641        } else if pass_by_ref {
642            format!("&{n}")
643        } else {
644            n.to_string()
645        }
646    };
647    if optional && value.is_null() {
648        (vec![format!("let {name} = None;")], expr(name))
649    } else if optional {
650        (vec![format!("let {name} = Some({literal});")], expr(name))
651    } else {
652        (vec![format!("let {name} = {literal};")], expr(name))
653    }
654}
655
656/// Render a `json_object` argument: serialize the fixture JSON as a `serde_json::json!` literal
657/// and deserialize it through serde at runtime. Type inference from the function signature
658/// determines the concrete type, keeping the generator generic.
659fn render_json_object_arg(
660    name: &str,
661    value: &serde_json::Value,
662    optional: bool,
663    _module: &str,
664) -> (Vec<String>, String) {
665    if value.is_null() && optional {
666        // Use Default::default() and pass by reference — Rust functions typically
667        // take &T not Option<T> for config params.
668        return (vec![format!("let {name} = Default::default();")], format!("&{name}"));
669    }
670
671    // Fixture keys are camelCase; the Rust ConversionOptions type uses snake_case serde.
672    // Normalize keys before building the json! literal so deserialization succeeds.
673    let normalized = super::normalize_json_keys_to_snake_case(value);
674    // Build the json! macro invocation from the fixture object.
675    let json_literal = json_value_to_macro_literal(&normalized);
676    let mut lines = Vec::new();
677    lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
678    // Deserialize to a concrete type inferred from the function signature.
679    let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
680    if optional {
681        lines.push(format!("let {name} = Some({deser_expr});"));
682        (lines, format!("&{name}"))
683    } else {
684        lines.push(format!("let {name} = {deser_expr};"));
685        (lines, format!("&{name}"))
686    }
687}
688
689/// Convert a `serde_json::Value` into a string suitable for the `serde_json::json!()` macro.
690fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
691    match value {
692        serde_json::Value::Null => "null".to_string(),
693        serde_json::Value::Bool(b) => format!("{b}"),
694        serde_json::Value::Number(n) => n.to_string(),
695        serde_json::Value::String(s) => {
696            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
697            format!("\"{escaped}\"")
698        }
699        serde_json::Value::Array(arr) => {
700            let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
701            format!("[{}]", items.join(", "))
702        }
703        serde_json::Value::Object(obj) => {
704            let entries: Vec<String> = obj
705                .iter()
706                .map(|(k, v)| {
707                    let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
708                    format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
709                })
710                .collect();
711            format!("{{{}}}", entries.join(", "))
712        }
713    }
714}
715
716fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
717    match value {
718        serde_json::Value::Null => "None".to_string(),
719        serde_json::Value::Bool(b) => format!("{b}"),
720        serde_json::Value::Number(n) => {
721            if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
722                if let Some(f) = n.as_f64() {
723                    return format!("{f}_f64");
724                }
725            }
726            n.to_string()
727        }
728        serde_json::Value::String(s) => rust_raw_string(s),
729        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
730            let json_str = serde_json::to_string(value).unwrap_or_default();
731            let literal = rust_raw_string(&json_str);
732            format!("serde_json::from_str({literal}).unwrap()")
733        }
734    }
735}
736
737// ---------------------------------------------------------------------------
738// Mock server helpers
739// ---------------------------------------------------------------------------
740
741/// Emit mock server setup lines into a test function body.
742///
743/// Builds `MockRoute` objects from the fixture's `mock_response` and starts
744/// the server.  The resulting `mock_server` variable is in scope for the rest
745/// of the test function.
746fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
747    let mock = match fixture.mock_response.as_ref() {
748        Some(m) => m,
749        None => return,
750    };
751
752    // Resolve the HTTP path and method from the call config.
753    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
754    let path = call_config.path.as_deref().unwrap_or("/");
755    let method = call_config.method.as_deref().unwrap_or("POST");
756
757    let status = mock.status;
758
759    if let Some(chunks) = &mock.stream_chunks {
760        // Streaming SSE response.
761        let _ = writeln!(out, "    let mock_route = MockRoute {{");
762        let _ = writeln!(out, "        path: \"{path}\",");
763        let _ = writeln!(out, "        method: \"{method}\",");
764        let _ = writeln!(out, "        status: {status},");
765        let _ = writeln!(out, "        body: String::new(),");
766        let _ = writeln!(out, "        stream_chunks: vec![");
767        for chunk in chunks {
768            let chunk_str = match chunk {
769                serde_json::Value::String(s) => rust_raw_string(s),
770                other => {
771                    let s = serde_json::to_string(other).unwrap_or_default();
772                    rust_raw_string(&s)
773                }
774            };
775            let _ = writeln!(out, "            {chunk_str}.to_string(),");
776        }
777        let _ = writeln!(out, "        ],");
778        let _ = writeln!(out, "    }};");
779    } else {
780        // Non-streaming JSON response.
781        let body_str = match &mock.body {
782            Some(b) => {
783                let s = serde_json::to_string(b).unwrap_or_default();
784                rust_raw_string(&s)
785            }
786            None => rust_raw_string("{}"),
787        };
788        let _ = writeln!(out, "    let mock_route = MockRoute {{");
789        let _ = writeln!(out, "        path: \"{path}\",");
790        let _ = writeln!(out, "        method: \"{method}\",");
791        let _ = writeln!(out, "        status: {status},");
792        let _ = writeln!(out, "        body: {body_str}.to_string(),");
793        let _ = writeln!(out, "        stream_chunks: vec![],");
794        let _ = writeln!(out, "    }};");
795    }
796
797    let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
798}
799
800/// Generate the complete `mock_server.rs` module source.
801fn render_mock_server_module() -> String {
802    // This is parameterized Axum mock server code identical in structure to
803    // liter-llm's mock_server.rs but without any project-specific imports.
804    r#"// This file is auto-generated by alef. DO NOT EDIT.
805//
806// Minimal axum-based mock HTTP server for e2e tests.
807
808use std::net::SocketAddr;
809use std::sync::Arc;
810
811use axum::Router;
812use axum::body::Body;
813use axum::extract::State;
814use axum::http::{Request, StatusCode};
815use axum::response::{IntoResponse, Response};
816use tokio::net::TcpListener;
817
818/// A single mock route: match by path + method, return a configured response.
819#[derive(Clone, Debug)]
820pub struct MockRoute {
821    /// URL path to match, e.g. `"/v1/chat/completions"`.
822    pub path: &'static str,
823    /// HTTP method to match, e.g. `"POST"` or `"GET"`.
824    pub method: &'static str,
825    /// HTTP status code to return.
826    pub status: u16,
827    /// Response body JSON string (used when `stream_chunks` is empty).
828    pub body: String,
829    /// Ordered SSE data payloads for streaming responses.
830    /// Each entry becomes `data: <chunk>\n\n` in the response.
831    /// A final `data: [DONE]\n\n` is always appended.
832    pub stream_chunks: Vec<String>,
833}
834
835struct ServerState {
836    routes: Vec<MockRoute>,
837}
838
839pub struct MockServer {
840    /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
841    pub url: String,
842    handle: tokio::task::JoinHandle<()>,
843}
844
845impl MockServer {
846    /// Start a mock server with the given routes.  Binds to a random port on
847    /// localhost and returns immediately once the server is listening.
848    pub async fn start(routes: Vec<MockRoute>) -> Self {
849        let state = Arc::new(ServerState { routes });
850
851        let app = Router::new().fallback(handle_request).with_state(state);
852
853        let listener = TcpListener::bind("127.0.0.1:0")
854            .await
855            .expect("Failed to bind mock server port");
856        let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
857        let url = format!("http://{addr}");
858
859        let handle = tokio::spawn(async move {
860            axum::serve(listener, app).await.expect("Mock server failed");
861        });
862
863        MockServer { url, handle }
864    }
865
866    /// Stop the mock server.
867    pub fn shutdown(self) {
868        self.handle.abort();
869    }
870}
871
872impl Drop for MockServer {
873    fn drop(&mut self) {
874        self.handle.abort();
875    }
876}
877
878async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
879    let path = req.uri().path().to_owned();
880    let method = req.method().as_str().to_uppercase();
881
882    for route in &state.routes {
883        if route.path == path && route.method.to_uppercase() == method {
884            let status =
885                StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
886
887            if !route.stream_chunks.is_empty() {
888                // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
889                let mut sse = String::new();
890                for chunk in &route.stream_chunks {
891                    sse.push_str("data: ");
892                    sse.push_str(chunk);
893                    sse.push_str("\n\n");
894                }
895                sse.push_str("data: [DONE]\n\n");
896
897                return Response::builder()
898                    .status(status)
899                    .header("content-type", "text/event-stream")
900                    .header("cache-control", "no-cache")
901                    .body(Body::from(sse))
902                    .unwrap()
903                    .into_response();
904            }
905
906            return Response::builder()
907                .status(status)
908                .header("content-type", "application/json")
909                .body(Body::from(route.body.clone()))
910                .unwrap()
911                .into_response();
912        }
913    }
914
915    // No matching route → 404.
916    Response::builder()
917        .status(StatusCode::NOT_FOUND)
918        .body(Body::from(format!("No mock route for {method} {path}")))
919        .unwrap()
920        .into_response()
921}
922"#
923    .to_string()
924}
925
926/// Generate the `src/main.rs` for the standalone mock server binary.
927///
928/// The binary:
929/// - Reads all `*.json` fixture files from a fixtures directory (default `../../fixtures`).
930/// - For each fixture that has a `mock_response` field, registers a route at
931///   `/fixtures/{fixture_id}` returning the configured status/body/SSE chunks.
932/// - Binds to `127.0.0.1:0` (random port), prints `MOCK_SERVER_URL=http://...`
933///   to stdout, then waits until stdin is closed for clean teardown.
934///
935/// This binary is intended for cross-language e2e suites (WASM, Node) that
936/// spawn it as a child process and read the URL from its stdout.
937fn render_mock_server_binary() -> String {
938    r#"// This file is auto-generated by alef. DO NOT EDIT.
939//
940// Standalone mock HTTP server binary for cross-language e2e tests.
941// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
942//
943// Usage: mock-server [fixtures-dir]
944//   fixtures-dir defaults to "../../fixtures"
945//
946// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
947// then blocks until stdin is closed (parent process exit triggers cleanup).
948
949use std::collections::HashMap;
950use std::io::{self, BufRead};
951use std::net::SocketAddr;
952use std::path::Path;
953use std::sync::Arc;
954
955use axum::Router;
956use axum::body::Body;
957use axum::extract::State;
958use axum::http::{Request, StatusCode};
959use axum::response::{IntoResponse, Response};
960use serde::Deserialize;
961use tokio::net::TcpListener;
962
963// ---------------------------------------------------------------------------
964// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
965// ---------------------------------------------------------------------------
966
967#[derive(Debug, Deserialize)]
968struct MockResponse {
969    status: u16,
970    #[serde(default)]
971    body: Option<serde_json::Value>,
972    #[serde(default)]
973    stream_chunks: Option<Vec<serde_json::Value>>,
974}
975
976#[derive(Debug, Deserialize)]
977struct Fixture {
978    id: String,
979    #[serde(default)]
980    mock_response: Option<MockResponse>,
981}
982
983// ---------------------------------------------------------------------------
984// Route table
985// ---------------------------------------------------------------------------
986
987#[derive(Clone, Debug)]
988struct MockRoute {
989    status: u16,
990    body: String,
991    stream_chunks: Vec<String>,
992}
993
994type RouteTable = Arc<HashMap<String, MockRoute>>;
995
996// ---------------------------------------------------------------------------
997// Axum handler
998// ---------------------------------------------------------------------------
999
1000async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1001    let path = req.uri().path().to_owned();
1002
1003    // Try exact match first
1004    if let Some(route) = routes.get(&path) {
1005        return serve_route(route);
1006    }
1007
1008    // Try prefix match: find a route that is a prefix of the request path
1009    // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1010    for (route_path, route) in routes.iter() {
1011        if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1012            return serve_route(route);
1013        }
1014    }
1015
1016    Response::builder()
1017        .status(StatusCode::NOT_FOUND)
1018        .body(Body::from(format!("No mock route for {path}")))
1019        .unwrap()
1020        .into_response()
1021}
1022
1023fn serve_route(route: &MockRoute) -> Response {
1024    let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1025
1026    if !route.stream_chunks.is_empty() {
1027        let mut sse = String::new();
1028        for chunk in &route.stream_chunks {
1029            sse.push_str("data: ");
1030            sse.push_str(chunk);
1031            sse.push_str("\n\n");
1032        }
1033        sse.push_str("data: [DONE]\n\n");
1034
1035        return Response::builder()
1036            .status(status)
1037            .header("content-type", "text/event-stream")
1038            .header("cache-control", "no-cache")
1039            .body(Body::from(sse))
1040            .unwrap()
1041            .into_response();
1042    }
1043
1044    Response::builder()
1045        .status(status)
1046        .header("content-type", "application/json")
1047        .body(Body::from(route.body.clone()))
1048        .unwrap()
1049        .into_response()
1050}
1051
1052// ---------------------------------------------------------------------------
1053// Fixture loading
1054// ---------------------------------------------------------------------------
1055
1056fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1057    let mut routes = HashMap::new();
1058    load_routes_recursive(fixtures_dir, &mut routes);
1059    routes
1060}
1061
1062fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1063    let entries = match std::fs::read_dir(dir) {
1064        Ok(e) => e,
1065        Err(err) => {
1066            eprintln!("warning: cannot read directory {}: {err}", dir.display());
1067            return;
1068        }
1069    };
1070
1071    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1072    paths.sort();
1073
1074    for path in paths {
1075        if path.is_dir() {
1076            load_routes_recursive(&path, routes);
1077        } else if path.extension().is_some_and(|ext| ext == "json") {
1078            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1079            if filename == "schema.json" || filename.starts_with('_') {
1080                continue;
1081            }
1082            let content = match std::fs::read_to_string(&path) {
1083                Ok(c) => c,
1084                Err(err) => {
1085                    eprintln!("warning: cannot read {}: {err}", path.display());
1086                    continue;
1087                }
1088            };
1089            let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1090                match serde_json::from_str(&content) {
1091                    Ok(v) => v,
1092                    Err(err) => {
1093                        eprintln!("warning: cannot parse {}: {err}", path.display());
1094                        continue;
1095                    }
1096                }
1097            } else {
1098                match serde_json::from_str::<Fixture>(&content) {
1099                    Ok(f) => vec![f],
1100                    Err(err) => {
1101                        eprintln!("warning: cannot parse {}: {err}", path.display());
1102                        continue;
1103                    }
1104                }
1105            };
1106
1107            for fixture in fixtures {
1108                if let Some(mock) = fixture.mock_response {
1109                    let route_path = format!("/fixtures/{}", fixture.id);
1110                    let body = mock
1111                        .body
1112                        .as_ref()
1113                        .map(|b| serde_json::to_string(b).unwrap_or_default())
1114                        .unwrap_or_default();
1115                    let stream_chunks = mock
1116                        .stream_chunks
1117                        .unwrap_or_default()
1118                        .into_iter()
1119                        .map(|c| match c {
1120                            serde_json::Value::String(s) => s,
1121                            other => serde_json::to_string(&other).unwrap_or_default(),
1122                        })
1123                        .collect();
1124                    routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks });
1125                }
1126            }
1127        }
1128    }
1129}
1130
1131// ---------------------------------------------------------------------------
1132// Entry point
1133// ---------------------------------------------------------------------------
1134
1135#[tokio::main]
1136async fn main() {
1137    let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1138    let fixtures_dir = Path::new(&fixtures_dir_arg);
1139
1140    let routes = load_routes(fixtures_dir);
1141    eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1142
1143    let route_table: RouteTable = Arc::new(routes);
1144    let app = Router::new().fallback(handle_request).with_state(route_table);
1145
1146    let listener = TcpListener::bind("127.0.0.1:0")
1147        .await
1148        .expect("mock-server: failed to bind port");
1149    let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1150
1151    // Print the URL so the parent process can read it.
1152    println!("MOCK_SERVER_URL=http://{addr}");
1153    // Flush stdout explicitly so the parent does not block waiting.
1154    use std::io::Write;
1155    std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1156
1157    // Spawn the server in the background.
1158    tokio::spawn(async move {
1159        axum::serve(listener, app).await.expect("mock-server: server error");
1160    });
1161
1162    // Block until stdin is closed — the parent process controls lifetime.
1163    let stdin = io::stdin();
1164    let mut lines = stdin.lock().lines();
1165    while lines.next().is_some() {}
1166}
1167"#
1168    .to_string()
1169}
1170
1171// ---------------------------------------------------------------------------
1172// Assertion rendering
1173// ---------------------------------------------------------------------------
1174
1175#[allow(clippy::too_many_arguments)]
1176fn render_assertion(
1177    out: &mut String,
1178    assertion: &Assertion,
1179    result_var: &str,
1180    module: &str,
1181    _dep_name: &str,
1182    is_error_context: bool,
1183    unwrapped_fields: &[(String, String)], // (fixture_field, local_var)
1184    field_resolver: &FieldResolver,
1185    result_is_tree: bool,
1186) {
1187    // Skip assertions on fields that don't exist on the result type.
1188    if let Some(f) = &assertion.field {
1189        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1190            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
1191            return;
1192        }
1193    }
1194
1195    // Determine field access expression:
1196    // 1. If the field was unwrapped to a local var, use that local var name.
1197    // 2. When the result is a Tree, map pseudo-field names to correct Rust expressions.
1198    // 3. Otherwise, use the field resolver to generate the accessor.
1199    let field_access = match &assertion.field {
1200        Some(f) if !f.is_empty() => {
1201            if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
1202                local_var.clone()
1203            } else if result_is_tree {
1204                // Tree is an opaque type — its "fields" are accessed via root_node() or
1205                // free functions. Map known pseudo-field names to correct Rust expressions.
1206                tree_field_access_expr(f, result_var, module)
1207            } else {
1208                field_resolver.accessor(f, "rust", result_var)
1209            }
1210        }
1211        _ => result_var.to_string(),
1212    };
1213
1214    // Check if this field was unwrapped (i.e., it is optional and was bound to a local).
1215    let is_unwrapped = assertion
1216        .field
1217        .as_ref()
1218        .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
1219
1220    match assertion.assertion_type.as_str() {
1221        "error" => {
1222            let _ = writeln!(out, "    assert!({result_var}.is_err(), \"expected call to fail\");");
1223            if let Some(serde_json::Value::String(msg)) = &assertion.value {
1224                let escaped = escape_rust(msg);
1225                let _ = writeln!(
1226                    out,
1227                    "    assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
1228                );
1229            }
1230        }
1231        "not_error" => {
1232            // Handled at call site; nothing extra needed here.
1233        }
1234        "equals" => {
1235            if let Some(val) = &assertion.value {
1236                let expected = value_to_rust_string(val);
1237                if is_error_context {
1238                    return;
1239                }
1240                // For string equality, trim trailing whitespace to handle trailing newlines
1241                // from the converter.
1242                if val.is_string() {
1243                    let _ = writeln!(
1244                        out,
1245                        "    assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
1246                    );
1247                } else if val.is_boolean() {
1248                    // Use assert!/assert!(!...) for booleans — clippy prefers this over assert_eq!(_, true/false).
1249                    if val.as_bool() == Some(true) {
1250                        let _ = writeln!(out, "    assert!({field_access}, \"equals assertion failed\");");
1251                    } else {
1252                        let _ = writeln!(out, "    assert!(!{field_access}, \"equals assertion failed\");");
1253                    }
1254                } else {
1255                    // Wrap expected value in Some() for optional fields.
1256                    let is_opt = assertion.field.as_ref().is_some_and(|f| {
1257                        let resolved = field_resolver.resolve(f);
1258                        field_resolver.is_optional(resolved)
1259                    });
1260                    if is_opt
1261                        && !unwrapped_fields
1262                            .iter()
1263                            .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
1264                    {
1265                        let _ = writeln!(
1266                            out,
1267                            "    assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
1268                        );
1269                    } else {
1270                        let _ = writeln!(
1271                            out,
1272                            "    assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
1273                        );
1274                    }
1275                }
1276            }
1277        }
1278        "contains" => {
1279            if let Some(val) = &assertion.value {
1280                let expected = value_to_rust_string(val);
1281                let line = format!(
1282                    "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1283                );
1284                let _ = writeln!(out, "{line}");
1285            }
1286        }
1287        "contains_all" => {
1288            if let Some(values) = &assertion.values {
1289                for val in values {
1290                    let expected = value_to_rust_string(val);
1291                    let line = format!(
1292                        "    assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1293                    );
1294                    let _ = writeln!(out, "{line}");
1295                }
1296            }
1297        }
1298        "not_contains" => {
1299            if let Some(val) = &assertion.value {
1300                let expected = value_to_rust_string(val);
1301                let line = format!(
1302                    "    assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
1303                );
1304                let _ = writeln!(out, "{line}");
1305            }
1306        }
1307        "not_empty" => {
1308            if let Some(f) = &assertion.field {
1309                let resolved = field_resolver.resolve(f);
1310                if !is_unwrapped && field_resolver.is_optional(resolved) {
1311                    // Non-string optional field (e.g., Option<Struct>): use is_some()
1312                    let accessor = field_resolver.accessor(f, "rust", result_var);
1313                    let _ = writeln!(
1314                        out,
1315                        "    assert!({accessor}.is_some(), \"expected {f} to be present\");"
1316                    );
1317                } else {
1318                    let _ = writeln!(
1319                        out,
1320                        "    assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
1321                    );
1322                }
1323            } else {
1324                // No field: assertion on the result itself. Use is_some() for Option types.
1325                let _ = writeln!(
1326                    out,
1327                    "    assert!({field_access}.is_some(), \"expected non-empty value\");"
1328                );
1329            }
1330        }
1331        "is_empty" => {
1332            if let Some(f) = &assertion.field {
1333                let resolved = field_resolver.resolve(f);
1334                if !is_unwrapped && field_resolver.is_optional(resolved) {
1335                    let accessor = field_resolver.accessor(f, "rust", result_var);
1336                    let _ = writeln!(out, "    assert!({accessor}.is_none(), \"expected {f} to be absent\");");
1337                } else {
1338                    let _ = writeln!(out, "    assert!({field_access}.is_empty(), \"expected empty value\");");
1339                }
1340            } else {
1341                // No field: assertion on the result itself. Use is_none() for Option types.
1342                let _ = writeln!(out, "    assert!({field_access}.is_none(), \"expected empty value\");");
1343            }
1344        }
1345        "contains_any" => {
1346            if let Some(values) = &assertion.values {
1347                let checks: Vec<String> = values
1348                    .iter()
1349                    .map(|v| {
1350                        let expected = value_to_rust_string(v);
1351                        format!("{field_access}.contains({expected})")
1352                    })
1353                    .collect();
1354                let joined = checks.join(" || ");
1355                let _ = writeln!(
1356                    out,
1357                    "    assert!({joined}, \"expected to contain at least one of the specified values\");"
1358                );
1359            }
1360        }
1361        "greater_than" => {
1362            if let Some(val) = &assertion.value {
1363                // Skip comparisons with negative values against unsigned types (.len() etc.)
1364                if val.as_f64().is_some_and(|n| n < 0.0) {
1365                    let _ = writeln!(
1366                        out,
1367                        "    // skipped: greater_than with negative value is always true for unsigned types"
1368                    );
1369                } else if val.as_u64() == Some(0) {
1370                    // Clippy prefers !is_empty() over len() > 0
1371                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1372                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected > 0\");");
1373                } else {
1374                    let lit = numeric_literal(val);
1375                    let _ = writeln!(out, "    assert!({field_access} > {lit}, \"expected > {lit}\");");
1376                }
1377            }
1378        }
1379        "less_than" => {
1380            if let Some(val) = &assertion.value {
1381                let lit = numeric_literal(val);
1382                let _ = writeln!(out, "    assert!({field_access} < {lit}, \"expected < {lit}\");");
1383            }
1384        }
1385        "greater_than_or_equal" => {
1386            if let Some(val) = &assertion.value {
1387                let lit = numeric_literal(val);
1388                if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
1389                    // Clippy prefers !is_empty() over len() >= 1 for collections.
1390                    // Only apply when the expression is already a `.len()` call so we
1391                    // don't mistakenly call `.is_empty()` on numeric (usize) fields.
1392                    let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1393                    let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= 1\");");
1394                } else {
1395                    let _ = writeln!(out, "    assert!({field_access} >= {lit}, \"expected >= {lit}\");");
1396                }
1397            }
1398        }
1399        "less_than_or_equal" => {
1400            if let Some(val) = &assertion.value {
1401                let lit = numeric_literal(val);
1402                let _ = writeln!(out, "    assert!({field_access} <= {lit}, \"expected <= {lit}\");");
1403            }
1404        }
1405        "starts_with" => {
1406            if let Some(val) = &assertion.value {
1407                let expected = value_to_rust_string(val);
1408                let _ = writeln!(
1409                    out,
1410                    "    assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
1411                );
1412            }
1413        }
1414        "ends_with" => {
1415            if let Some(val) = &assertion.value {
1416                let expected = value_to_rust_string(val);
1417                let _ = writeln!(
1418                    out,
1419                    "    assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
1420                );
1421            }
1422        }
1423        "min_length" => {
1424            if let Some(val) = &assertion.value {
1425                if let Some(n) = val.as_u64() {
1426                    let _ = writeln!(
1427                        out,
1428                        "    assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
1429                    );
1430                }
1431            }
1432        }
1433        "max_length" => {
1434            if let Some(val) = &assertion.value {
1435                if let Some(n) = val.as_u64() {
1436                    let _ = writeln!(
1437                        out,
1438                        "    assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
1439                    );
1440                }
1441            }
1442        }
1443        "count_min" => {
1444            if let Some(val) = &assertion.value {
1445                if let Some(n) = val.as_u64() {
1446                    if n <= 1 {
1447                        // Clippy prefers !is_empty() over len() >= 1
1448                        let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1449                        let _ = writeln!(out, "    assert!(!{base}.is_empty(), \"expected >= {n}\");");
1450                    } else {
1451                        let _ = writeln!(
1452                            out,
1453                            "    assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
1454                        );
1455                    }
1456                }
1457            }
1458        }
1459        "count_equals" => {
1460            if let Some(val) = &assertion.value {
1461                if let Some(n) = val.as_u64() {
1462                    let _ = writeln!(
1463                        out,
1464                        "    assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
1465                    );
1466                }
1467            }
1468        }
1469        "is_true" => {
1470            let _ = writeln!(out, "    assert!({field_access}, \"expected true\");");
1471        }
1472        "method_result" => {
1473            if let Some(method_name) = &assertion.method {
1474                // Build the call expression. When the result is a tree-sitter Tree (an opaque
1475                // type), methods like `root_child_count` do not exist on `Tree` directly —
1476                // they are free functions in the crate or are accessed via `root_node()`.
1477                let call_expr = if result_is_tree {
1478                    build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
1479                } else if let Some(args) = &assertion.args {
1480                    let arg_lit = json_to_rust_literal(args, "");
1481                    format!("{field_access}.{method_name}({arg_lit})")
1482                } else {
1483                    format!("{field_access}.{method_name}()")
1484                };
1485
1486                // Determine whether the call expression returns a numeric type so we can
1487                // choose the right comparison strategy for `greater_than_or_equal`.
1488                let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
1489
1490                let check = assertion.check.as_deref().unwrap_or("is_true");
1491                match check {
1492                    "equals" => {
1493                        if let Some(val) = &assertion.value {
1494                            if val.is_boolean() {
1495                                if val.as_bool() == Some(true) {
1496                                    let _ = writeln!(
1497                                        out,
1498                                        "    assert!({call_expr}, \"method_result equals assertion failed\");"
1499                                    );
1500                                } else {
1501                                    let _ = writeln!(
1502                                        out,
1503                                        "    assert!(!{call_expr}, \"method_result equals assertion failed\");"
1504                                    );
1505                                }
1506                            } else {
1507                                let expected = value_to_rust_string(val);
1508                                let _ = writeln!(
1509                                    out,
1510                                    "    assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
1511                                );
1512                            }
1513                        }
1514                    }
1515                    "is_true" => {
1516                        let _ = writeln!(
1517                            out,
1518                            "    assert!({call_expr}, \"method_result is_true assertion failed\");"
1519                        );
1520                    }
1521                    "is_false" => {
1522                        let _ = writeln!(
1523                            out,
1524                            "    assert!(!{call_expr}, \"method_result is_false assertion failed\");"
1525                        );
1526                    }
1527                    "greater_than_or_equal" => {
1528                        if let Some(val) = &assertion.value {
1529                            let lit = numeric_literal(val);
1530                            if returns_numeric {
1531                                // Numeric return (e.g., child_count()) — always use >= comparison.
1532                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1533                            } else if val.as_u64() == Some(1) {
1534                                // Clippy prefers !is_empty() over len() >= 1 for collections.
1535                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
1536                            } else {
1537                                let _ = writeln!(out, "    assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1538                            }
1539                        }
1540                    }
1541                    "count_min" => {
1542                        if let Some(val) = &assertion.value {
1543                            let n = val.as_u64().unwrap_or(0);
1544                            if n <= 1 {
1545                                let _ = writeln!(out, "    assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
1546                            } else {
1547                                let _ = writeln!(
1548                                    out,
1549                                    "    assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
1550                                );
1551                            }
1552                        }
1553                    }
1554                    other_check => {
1555                        let _ = writeln!(out, "    // TODO: unsupported method_result check type: {other_check}");
1556                    }
1557                }
1558            } else {
1559                let _ = writeln!(out, "    // TODO: method_result assertion missing 'method' field");
1560            }
1561        }
1562        other => {
1563            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
1564        }
1565    }
1566}
1567
1568/// Translate a fixture pseudo-field name on a `tree_sitter::Tree` into the
1569/// correct Rust accessor expression.
1570///
1571/// When an assertion uses `field: "root_child_count"` on a tree result, the
1572/// field resolver would naively emit `tree.root_child_count` — which is invalid
1573/// because `Tree` is an opaque type with no such field.  This function maps the
1574/// pseudo-field to the correct Rust expression instead.
1575fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
1576    match field {
1577        "root_child_count" => format!("{result_var}.root_node().child_count()"),
1578        "root_node_type" => format!("{result_var}.root_node().kind()"),
1579        "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1580        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
1581        "error_count" => format!("{module}::tree_error_count(&{result_var})"),
1582        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
1583        // Unknown pseudo-field: fall back to direct field access (will likely fail to compile,
1584        // but gives the developer a useful error pointing to the fixture).
1585        other => format!("{result_var}.{other}"),
1586    }
1587}
1588
1589/// Build a Rust call expression for a logical "method" on a `tree_sitter::Tree`.
1590///
1591/// `Tree` is an opaque type — it does not expose methods like `root_child_count`.
1592/// Instead, these are either free functions in the crate or are accessed via
1593/// `tree.root_node().<method>()`. This function translates the fixture-level
1594/// method name into the correct Rust expression.
1595fn build_tree_call_expr(
1596    field_access: &str,
1597    method_name: &str,
1598    args: Option<&serde_json::Value>,
1599    module: &str,
1600) -> String {
1601    match method_name {
1602        "root_child_count" => format!("{field_access}.root_node().child_count()"),
1603        "root_node_type" => format!("{field_access}.root_node().kind()"),
1604        "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
1605        "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
1606        "error_count" => format!("{module}::tree_error_count(&{field_access})"),
1607        "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
1608        "contains_node_type" => {
1609            let node_type = args
1610                .and_then(|a| a.get("node_type"))
1611                .and_then(|v| v.as_str())
1612                .unwrap_or("");
1613            format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
1614        }
1615        "find_nodes_by_type" => {
1616            let node_type = args
1617                .and_then(|a| a.get("node_type"))
1618                .and_then(|v| v.as_str())
1619                .unwrap_or("");
1620            format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
1621        }
1622        "run_query" => {
1623            let query_source = args
1624                .and_then(|a| a.get("query_source"))
1625                .and_then(|v| v.as_str())
1626                .unwrap_or("");
1627            let language = args
1628                .and_then(|a| a.get("language"))
1629                .and_then(|v| v.as_str())
1630                .unwrap_or("");
1631            // Use a raw string for the query to avoid escaping issues.
1632            // run_query returns Result — unwrap it for assertion access.
1633            format!(
1634                "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
1635            )
1636        }
1637        // Fallback: try as a plain method call.
1638        _ => {
1639            if let Some(args) = args {
1640                let arg_lit = json_to_rust_literal(args, "");
1641                format!("{field_access}.{method_name}({arg_lit})")
1642            } else {
1643                format!("{field_access}.{method_name}()")
1644            }
1645        }
1646    }
1647}
1648
1649/// Returns `true` when the tree method name produces a numeric result (usize/u64),
1650/// meaning `>= N` comparisons should use direct numeric comparison rather than
1651/// `.is_empty()` (which only works for collections).
1652fn is_tree_numeric_method(method_name: &str) -> bool {
1653    matches!(method_name, "root_child_count" | "named_children_count" | "error_count")
1654}
1655
1656/// Convert a JSON numeric value to a Rust literal suitable for comparisons.
1657///
1658/// Whole numbers (no fractional part) are emitted as bare integer literals so
1659/// they are compatible with `usize`, `u64`, etc. (e.g., `.len()` results).
1660/// Numbers with a fractional component get the `_f64` suffix.
1661fn numeric_literal(value: &serde_json::Value) -> String {
1662    if let Some(n) = value.as_f64() {
1663        if n.fract() == 0.0 {
1664            // Whole number — emit without a type suffix so Rust can infer the
1665            // correct integer type from context (usize, u64, i64, …).
1666            return format!("{}", n as i64);
1667        }
1668        return format!("{n}_f64");
1669    }
1670    // Fallback: use the raw JSON representation.
1671    value.to_string()
1672}
1673
1674fn value_to_rust_string(value: &serde_json::Value) -> String {
1675    match value {
1676        serde_json::Value::String(s) => rust_raw_string(s),
1677        serde_json::Value::Bool(b) => format!("{b}"),
1678        serde_json::Value::Number(n) => n.to_string(),
1679        other => {
1680            let s = other.to_string();
1681            format!("\"{s}\"")
1682        }
1683    }
1684}
1685
1686// ---------------------------------------------------------------------------
1687// Visitor generation
1688// ---------------------------------------------------------------------------
1689
1690/// Resolve the visitor trait name based on module.
1691fn resolve_visitor_trait(module: &str) -> String {
1692    // For html_to_markdown modules, use HtmlVisitor
1693    if module.contains("html_to_markdown") {
1694        "HtmlVisitor".to_string()
1695    } else {
1696        // Default fallback for other modules
1697        "Visitor".to_string()
1698    }
1699}
1700
1701/// Emit a Rust visitor method for a callback action.
1702fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1703    let params = match method_name {
1704        "visit_link" => "ctx, href, text, title",
1705        "visit_image" => "ctx, src, alt, title",
1706        "visit_heading" => "ctx, level, text, id",
1707        "visit_code_block" => "ctx, lang, code",
1708        "visit_code_inline"
1709        | "visit_strong"
1710        | "visit_emphasis"
1711        | "visit_strikethrough"
1712        | "visit_underline"
1713        | "visit_subscript"
1714        | "visit_superscript"
1715        | "visit_mark"
1716        | "visit_button"
1717        | "visit_summary"
1718        | "visit_figcaption"
1719        | "visit_definition_term"
1720        | "visit_definition_description" => "ctx, text",
1721        "visit_text" => "ctx, text",
1722        "visit_list_item" => "ctx, ordered, marker, text",
1723        "visit_blockquote" => "ctx, content, depth",
1724        "visit_table_row" => "ctx, cells, is_header",
1725        "visit_custom_element" => "ctx, tag_name, html",
1726        "visit_form" => "ctx, action_url, method",
1727        "visit_input" => "ctx, input_type, name, value",
1728        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1729        "visit_details" => "ctx, is_open",
1730        _ => "ctx",
1731    };
1732
1733    let _ = writeln!(out, "        fn {method_name}(&self, {params}) -> VisitResult {{");
1734    match action {
1735        CallbackAction::Skip => {
1736            let _ = writeln!(out, "            VisitResult::Skip");
1737        }
1738        CallbackAction::Continue => {
1739            let _ = writeln!(out, "            VisitResult::Continue");
1740        }
1741        CallbackAction::PreserveHtml => {
1742            let _ = writeln!(out, "            VisitResult::PreserveHtml");
1743        }
1744        CallbackAction::Custom { output } => {
1745            let escaped = escape_rust(output);
1746            let _ = writeln!(out, "            VisitResult::Custom({escaped}.to_string())");
1747        }
1748        CallbackAction::CustomTemplate { template } => {
1749            let _ = writeln!(out, "            VisitResult::Custom(format!(\"{template}\"))");
1750        }
1751    }
1752    let _ = writeln!(out, "        }}");
1753}