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