Skip to main content

alef_e2e/codegen/
python.rs

1//! Python e2e test code generator.
2//!
3//! Generates `e2e/python/conftest.py` and `tests/test_{category}.py` files from
4//! JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_python, 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 alef_core::hash::{self, CommentStyle};
13use anyhow::Result;
14use heck::{ToShoutySnakeCase, ToSnakeCase};
15use std::collections::HashMap;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19/// Python e2e test code generator.
20pub struct PythonE2eCodegen;
21
22impl super::E2eCodegen for PythonE2eCodegen {
23    fn generate(
24        &self,
25        groups: &[FixtureGroup],
26        e2e_config: &E2eConfig,
27        _alef_config: &AlefConfig,
28    ) -> Result<Vec<GeneratedFile>> {
29        let mut files = Vec::new();
30        let output_base = PathBuf::from(e2e_config.effective_output()).join("python");
31
32        // conftest.py
33        files.push(GeneratedFile {
34            path: output_base.join("conftest.py"),
35            content: render_conftest(e2e_config, groups),
36            generated_header: true,
37        });
38
39        // Root __init__.py (prevents ruff INP001).
40        files.push(GeneratedFile {
41            path: output_base.join("__init__.py"),
42            content: String::new(),
43            generated_header: false,
44        });
45
46        // tests/__init__.py
47        files.push(GeneratedFile {
48            path: output_base.join("tests").join("__init__.py"),
49            content: String::new(),
50            generated_header: false,
51        });
52
53        // pyproject.toml for standalone uv resolution
54        let python_pkg = e2e_config.resolve_package("python");
55        let pkg_name = python_pkg
56            .as_ref()
57            .and_then(|p| p.name.as_deref())
58            .unwrap_or("kreuzcrawl");
59        let pkg_path = python_pkg
60            .as_ref()
61            .and_then(|p| p.path.as_deref())
62            .unwrap_or("../../packages/python");
63        let pkg_version = python_pkg
64            .as_ref()
65            .and_then(|p| p.version.as_deref())
66            .unwrap_or("0.1.0");
67        files.push(GeneratedFile {
68            path: output_base.join("pyproject.toml"),
69            content: render_pyproject(pkg_name, pkg_path, pkg_version, e2e_config.dep_mode),
70            generated_header: true,
71        });
72
73        // Per-category test files.
74        for group in groups {
75            let fixtures: Vec<&Fixture> = group.fixtures.iter().collect();
76
77            if fixtures.is_empty() {
78                continue;
79            }
80
81            let filename = format!("test_{}.py", sanitize_filename(&group.category));
82            let content = render_test_file(&group.category, &fixtures, e2e_config);
83
84            files.push(GeneratedFile {
85                path: output_base.join("tests").join(filename),
86                content,
87                generated_header: true,
88            });
89        }
90
91        Ok(files)
92    }
93
94    fn language_name(&self) -> &'static str {
95        "python"
96    }
97}
98
99// ---------------------------------------------------------------------------
100// pyproject.toml
101// ---------------------------------------------------------------------------
102
103fn render_pyproject(
104    pkg_name: &str,
105    _pkg_path: &str,
106    pkg_version: &str,
107    dep_mode: crate::config::DependencyMode,
108) -> String {
109    let dep_spec = match dep_mode {
110        crate::config::DependencyMode::Registry => {
111            format!(
112                "dependencies = [\"{pkg_name}{pkg_version}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n"
113            )
114        }
115        crate::config::DependencyMode::Local => {
116            format!(
117                "dependencies = [\"{pkg_name}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n\
118                 \n\
119                 [tool.uv.sources]\n\
120                 {pkg_name} = {{ workspace = true }}\n"
121            )
122        }
123    };
124
125    format!(
126        r#"[build-system]
127build-backend = "setuptools.build_meta"
128requires = ["setuptools>=68", "wheel"]
129
130[project]
131name = "{pkg_name}-e2e-tests"
132version = "0.0.0"
133description = "End-to-end tests"
134requires-python = ">=3.10"
135{dep_spec}
136[tool.setuptools]
137packages = []
138
139[tool.pytest.ini_options]
140asyncio_mode = "auto"
141testpaths = ["tests"]
142python_files = "test_*.py"
143python_functions = "test_*"
144addopts = "-v --strict-markers --tb=short"
145timeout = 300
146
147[tool.ruff.lint]
148ignore = ["PLR2004"]
149
150[tool.ruff.lint.per-file-ignores]
151"tests/**" = ["S101", "S108", "PT011", "B017"]
152"#
153    )
154}
155
156// ---------------------------------------------------------------------------
157// Config resolution helpers
158// ---------------------------------------------------------------------------
159
160fn resolve_function_name(e2e_config: &E2eConfig) -> String {
161    resolve_function_name_for_call(&e2e_config.call)
162}
163
164fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
165    call_config
166        .overrides
167        .get("python")
168        .and_then(|o| o.function.clone())
169        .unwrap_or_else(|| call_config.function.clone())
170}
171
172fn resolve_module(e2e_config: &E2eConfig) -> String {
173    e2e_config
174        .call
175        .overrides
176        .get("python")
177        .and_then(|o| o.module.clone())
178        .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
179}
180
181fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
182    e2e_config
183        .call
184        .overrides
185        .get("python")
186        .and_then(|o| o.options_type.clone())
187}
188
189/// Resolve how json_object args are passed: "kwargs" (default), "dict", or "json".
190fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
191    e2e_config
192        .call
193        .overrides
194        .get("python")
195        .and_then(|o| o.options_via.as_deref())
196        .unwrap_or("kwargs")
197}
198
199/// Resolve enum field mappings from the Python override config.
200fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
201    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
202    e2e_config
203        .call
204        .overrides
205        .get("python")
206        .map(|o| &o.enum_fields)
207        .unwrap_or(&EMPTY)
208}
209
210/// Resolve handle nested type mappings from the Python override config.
211/// Maps config field names to their Python constructor type names.
212fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
213    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
214    e2e_config
215        .call
216        .overrides
217        .get("python")
218        .map(|o| &o.handle_nested_types)
219        .unwrap_or(&EMPTY)
220}
221
222/// Resolve handle dict type set from the Python override config.
223/// Fields in this set use `TypeName({...})` instead of `TypeName(key=val, ...)`.
224fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
225    static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
226        std::sync::LazyLock::new(std::collections::HashSet::new);
227    e2e_config
228        .call
229        .overrides
230        .get("python")
231        .map(|o| &o.handle_dict_types)
232        .unwrap_or(&EMPTY)
233}
234
235fn is_skipped(fixture: &Fixture, language: &str) -> bool {
236    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
237}
238
239// ---------------------------------------------------------------------------
240// Rendering
241// ---------------------------------------------------------------------------
242
243fn render_conftest(e2e_config: &E2eConfig, groups: &[FixtureGroup]) -> String {
244    let module = resolve_module(e2e_config);
245    let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
246
247    let header = hash::header(CommentStyle::Hash);
248    if has_http_fixtures {
249        format!(
250            r#"{header}"""Pytest configuration for e2e tests."""
251import pytest
252
253# Ensure the package is importable.
254# The {module} package is expected to be installed in the current environment.
255
256
257@pytest.fixture
258def client(http_test_server):  # noqa: ANN001, ANN201
259    """Return a test client bound to the per-test HTTP server."""
260    return http_test_server.client()
261"#
262        )
263    } else {
264        format!(
265            r#"{header}"""Pytest configuration for e2e tests."""
266# Ensure the package is importable.
267# The {module} package is expected to be installed in the current environment.
268"#
269        )
270    }
271}
272
273fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
274    let mut out = String::new();
275    out.push_str(&hash::header(CommentStyle::Hash));
276    let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
277
278    let module = resolve_module(e2e_config);
279    let function_name = resolve_function_name(e2e_config);
280    let options_type = resolve_options_type(e2e_config);
281    let options_via = resolve_options_via(e2e_config);
282    let enum_fields = resolve_enum_fields(e2e_config);
283    let handle_nested_types = resolve_handle_nested_types(e2e_config);
284    let handle_dict_types = resolve_handle_dict_types(e2e_config);
285    let field_resolver = FieldResolver::new(
286        &e2e_config.fields,
287        &e2e_config.fields_optional,
288        &e2e_config.result_fields,
289        &e2e_config.fields_array,
290    );
291
292    let has_error_test = fixtures
293        .iter()
294        .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
295    let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
296    let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
297
298    // Check if any fixture in this file uses an async call.
299    let is_async = fixtures.iter().any(|f| {
300        let cc = e2e_config.resolve_call(f.call.as_deref());
301        cc.r#async
302    }) || e2e_config.call.r#async;
303    let needs_pytest = has_error_test || has_skipped || is_async;
304
305    // "json" mode needs `import json`.
306    let needs_json_import = options_via == "json"
307        && fixtures.iter().any(|f| {
308            e2e_config
309                .call
310                .args
311                .iter()
312                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
313        });
314
315    // mock_url args need `import os`.
316    let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
317
318    // HTTP tests with header UUID assertions need `import re`.
319    let needs_re_import = has_http_tests
320        && fixtures.iter().any(|f| {
321            f.http
322                .as_ref()
323                .is_some_and(|h| h.expected_response.headers.values().any(|v| v == "<<uuid>>"))
324        });
325
326    // Only import options_type when using "kwargs" mode.
327    let needs_options_type = options_via == "kwargs"
328        && options_type.is_some()
329        && fixtures.iter().any(|f| {
330            e2e_config
331                .call
332                .args
333                .iter()
334                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
335        });
336
337    // Collect enum types actually used across all fixtures in this file.
338    let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
339    if needs_options_type && !enum_fields.is_empty() {
340        for fixture in fixtures.iter() {
341            for arg in &e2e_config.call.args {
342                if arg.arg_type == "json_object" {
343                    let value = resolve_field(&fixture.input, &arg.field);
344                    if let Some(obj) = value.as_object() {
345                        for key in obj.keys() {
346                            if let Some(enum_type) = enum_fields.get(key) {
347                                used_enum_types.insert(enum_type.clone());
348                            }
349                        }
350                    }
351                }
352            }
353        }
354    }
355
356    // Collect imports sorted per isort/ruff I001: stdlib group, then
357    // third-party group, separated by a blank line. Within each group
358    // `import X` lines come before `from X import Y` lines, both sorted.
359    let mut stdlib_imports: Vec<String> = Vec::new();
360    let mut thirdparty_bare: Vec<String> = Vec::new();
361    let mut thirdparty_from: Vec<String> = Vec::new();
362
363    if needs_json_import {
364        stdlib_imports.push("import json".to_string());
365    }
366
367    if needs_os_import {
368        stdlib_imports.push("import os".to_string());
369    }
370
371    if needs_re_import {
372        stdlib_imports.push("import re".to_string());
373    }
374
375    if needs_pytest {
376        thirdparty_bare.push("import pytest".to_string());
377    }
378
379    // For non-HTTP fixtures, build the normal function imports.
380    let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
381    if has_non_http_fixtures {
382        // Collect handle constructor function names that need to be imported.
383        let handle_constructors: Vec<String> = e2e_config
384            .call
385            .args
386            .iter()
387            .filter(|arg| arg.arg_type == "handle")
388            .map(|arg| format!("create_{}", arg.name.to_snake_case()))
389            .collect();
390
391        // Collect all unique function names actually used across all fixtures in this file.
392        // Do not seed with the default function_name — only include it when at least one
393        // fixture resolves to it, to avoid unused-import (F401) warnings from ruff.
394        let mut import_names: Vec<String> = Vec::new();
395        for fixture in fixtures.iter() {
396            let cc = e2e_config.resolve_call(fixture.call.as_deref());
397            let fn_name = resolve_function_name_for_call(cc);
398            if !import_names.contains(&fn_name) {
399                import_names.push(fn_name);
400            }
401        }
402        // Safety net: should not occur since the group is non-empty, but ensures
403        // import_names is never empty if all fixtures use the default call.
404        if import_names.is_empty() {
405            import_names.push(function_name.clone());
406        }
407        for ctor in &handle_constructors {
408            if !import_names.contains(ctor) {
409                import_names.push(ctor.clone());
410            }
411        }
412
413        // If any handle arg has config, import the config class (CrawlConfig or options_type).
414        let needs_config_import = e2e_config.call.args.iter().any(|arg| {
415            arg.arg_type == "handle"
416                && fixtures.iter().any(|f| {
417                    let val = resolve_field(&f.input, &arg.field);
418                    !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
419                })
420        });
421        if needs_config_import {
422            let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
423            if !import_names.contains(&config_class.to_string()) {
424                import_names.push(config_class.to_string());
425            }
426        }
427
428        // Import any nested handle config types actually used in this file.
429        if !handle_nested_types.is_empty() {
430            let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
431            for fixture in fixtures.iter() {
432                for arg in &e2e_config.call.args {
433                    if arg.arg_type == "handle" {
434                        let config_value = resolve_field(&fixture.input, &arg.field);
435                        if let Some(obj) = config_value.as_object() {
436                            for key in obj.keys() {
437                                if let Some(type_name) = handle_nested_types.get(key) {
438                                    if obj[key].is_object() {
439                                        used_nested_types.insert(type_name.clone());
440                                    }
441                                }
442                            }
443                        }
444                    }
445                }
446            }
447            for type_name in used_nested_types {
448                if !import_names.contains(&type_name) {
449                    import_names.push(type_name);
450                }
451            }
452        }
453
454        // Collect method_result helper function imports.
455        for fixture in fixtures.iter() {
456            for assertion in &fixture.assertions {
457                if assertion.assertion_type == "method_result" {
458                    if let Some(method_name) = &assertion.method {
459                        let import = python_method_helper_import(method_name);
460                        if let Some(name) = import {
461                            if !import_names.contains(&name) {
462                                import_names.push(name);
463                            }
464                        }
465                    }
466                }
467            }
468        }
469
470        if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
471            import_names.push(opts_type.clone());
472            thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
473            // Import enum types from enum_module (if specified) or main module.
474            if !used_enum_types.is_empty() {
475                let enum_mod = e2e_config
476                    .call
477                    .overrides
478                    .get("python")
479                    .and_then(|o| o.enum_module.as_deref())
480                    .unwrap_or(&module);
481                let enum_names: Vec<&String> = used_enum_types.iter().collect();
482                thirdparty_from.push(format!(
483                    "from {enum_mod} import {}",
484                    enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
485                ));
486            }
487        } else {
488            thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
489        }
490    }
491
492    stdlib_imports.sort();
493    thirdparty_bare.sort();
494    thirdparty_from.sort();
495
496    // Emit sorted import groups with blank lines between groups per PEP 8.
497    if !stdlib_imports.is_empty() {
498        for imp in &stdlib_imports {
499            let _ = writeln!(out, "{imp}");
500        }
501        let _ = writeln!(out);
502    }
503    // Third-party: bare imports then from-imports, no blank line between them.
504    for imp in &thirdparty_bare {
505        let _ = writeln!(out, "{imp}");
506    }
507    for imp in &thirdparty_from {
508        let _ = writeln!(out, "{imp}");
509    }
510    // Two blank lines after imports (PEP 8 / ruff I001).
511    let _ = writeln!(out);
512    let _ = writeln!(out);
513
514    for fixture in fixtures {
515        if fixture.is_http_test() {
516            render_http_test_function(&mut out, fixture);
517        } else {
518            render_test_function(
519                &mut out,
520                fixture,
521                e2e_config,
522                options_type.as_deref(),
523                options_via,
524                enum_fields,
525                handle_nested_types,
526                handle_dict_types,
527                &field_resolver,
528            );
529        }
530        let _ = writeln!(out);
531    }
532
533    out
534}
535
536// ---------------------------------------------------------------------------
537// HTTP server test rendering
538// ---------------------------------------------------------------------------
539
540/// Render a pytest test function for an HTTP server fixture.
541///
542/// The generated test:
543/// 1. Receives a `client` fixture from conftest.py (the test server client).
544/// 2. Sends the configured request.
545/// 3. Asserts status code, body (exact or partial), headers, and validation errors.
546fn render_http_test_function(out: &mut String, fixture: &Fixture) {
547    let Some(http) = &fixture.http else {
548        return;
549    };
550
551    let fn_name = sanitize_ident(&fixture.id);
552    let description = &fixture.description;
553    let desc_with_period = if description.ends_with('.') {
554        description.to_string()
555    } else {
556        format!("{description}.")
557    };
558
559    if is_skipped(fixture, "python") {
560        let reason = fixture
561            .skip
562            .as_ref()
563            .and_then(|s| s.reason.as_deref())
564            .unwrap_or("skipped for python");
565        let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
566    }
567
568    let _ = writeln!(out, "def test_{fn_name}(client) -> None:");
569    let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
570
571    // Build the request call.
572    let method = http.request.method.to_lowercase();
573    let path = &http.request.path;
574
575    // Collect keyword arguments for the request method call.
576    let mut call_kwargs: Vec<String> = Vec::new();
577
578    // JSON body
579    if let Some(body) = &http.request.body {
580        let py_body = json_to_python_literal(body);
581        call_kwargs.push(format!("        json={py_body},"));
582    }
583
584    // Request headers
585    if !http.request.headers.is_empty() {
586        let entries: Vec<String> = http
587            .request
588            .headers
589            .iter()
590            .map(|(k, v)| format!("            \"{}\": \"{}\",", escape_python(k), escape_python(v)))
591            .collect();
592        let headers_block = entries.join("\n");
593        call_kwargs.push(format!("        headers={{\n{headers_block}\n        }},"));
594    }
595
596    // Query params
597    if !http.request.query_params.is_empty() {
598        let entries: Vec<String> = http
599            .request
600            .query_params
601            .iter()
602            .map(|(k, v)| format!("            \"{}\": {},", escape_python(k), json_to_python_literal(v)))
603            .collect();
604        let params_block = entries.join("\n");
605        call_kwargs.push(format!("        params={{\n{params_block}\n        }},"));
606    }
607
608    // Cookies
609    if !http.request.cookies.is_empty() {
610        let entries: Vec<String> = http
611            .request
612            .cookies
613            .iter()
614            .map(|(k, v)| format!("            \"{}\": \"{}\",", escape_python(k), escape_python(v)))
615            .collect();
616        let cookies_block = entries.join("\n");
617        call_kwargs.push(format!("        cookies={{\n{cookies_block}\n        }},"));
618    }
619
620    if call_kwargs.is_empty() {
621        let _ = writeln!(out, "    response = client.{method}(\"{path}\")");
622    } else {
623        let _ = writeln!(out, "    response = client.{method}(");
624        let _ = writeln!(out, "        \"{path}\",");
625        for kwarg in &call_kwargs {
626            let _ = writeln!(out, "{kwarg}");
627        }
628        let _ = writeln!(out, "    )");
629    }
630
631    // Status code assertion.
632    let status = http.expected_response.status_code;
633    let _ = writeln!(out, "    assert response.status_code == {status}  # noqa: S101");
634
635    // Body assertions.
636    if let Some(expected_body) = &http.expected_response.body {
637        let py_val = json_to_python_literal(expected_body);
638        let _ = writeln!(out, "    data = response.json()");
639        let _ = writeln!(out, "    assert data == {py_val}  # noqa: S101");
640    } else if let Some(partial) = &http.expected_response.body_partial {
641        let _ = writeln!(out, "    data = response.json()");
642        if let Some(obj) = partial.as_object() {
643            for (key, val) in obj {
644                let py_val = json_to_python_literal(val);
645                let escaped_key = escape_python(key);
646                let _ = writeln!(out, "    assert data[\"{escaped_key}\"] == {py_val}  # noqa: S101");
647            }
648        }
649    }
650
651    // Header assertions.
652    for (header_name, header_value) in &http.expected_response.headers {
653        let lower_name = header_name.to_lowercase();
654        let escaped_name = escape_python(&lower_name);
655        match header_value.as_str() {
656            "<<present>>" => {
657                let _ = writeln!(out, "    assert \"{escaped_name}\" in response.headers  # noqa: S101");
658            }
659            "<<absent>>" => {
660                let _ = writeln!(
661                    out,
662                    "    assert response.headers.get(\"{escaped_name}\") is None  # noqa: S101"
663                );
664            }
665            "<<uuid>>" => {
666                let _ = writeln!(
667                    out,
668                    "    assert re.match(r'^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$', response.headers[\"{escaped_name}\"])  # noqa: S101"
669                );
670            }
671            exact => {
672                let escaped_val = escape_python(exact);
673                let _ = writeln!(
674                    out,
675                    "    assert response.headers[\"{escaped_name}\"] == \"{escaped_val}\"  # noqa: S101"
676                );
677            }
678        }
679    }
680
681    // Validation error assertions.
682    if let Some(validation_errors) = &http.expected_response.validation_errors {
683        if !validation_errors.is_empty() {
684            let _ = writeln!(out, "    errors = response.json().get(\"detail\", [])");
685            for ve in validation_errors {
686                let loc_py: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_python(s))).collect();
687                let loc_str = loc_py.join(", ");
688                let escaped_msg = escape_python(&ve.msg);
689                let _ = writeln!(
690                    out,
691                    "    assert any(e[\"loc\"] == [{loc_str}] and \"{escaped_msg}\" in e[\"msg\"] for e in errors)  # noqa: S101"
692                );
693            }
694        }
695    }
696}
697
698// ---------------------------------------------------------------------------
699// Function-call test rendering
700// ---------------------------------------------------------------------------
701
702#[allow(clippy::too_many_arguments)]
703fn render_test_function(
704    out: &mut String,
705    fixture: &Fixture,
706    e2e_config: &E2eConfig,
707    options_type: Option<&str>,
708    options_via: &str,
709    enum_fields: &HashMap<String, String>,
710    handle_nested_types: &HashMap<String, String>,
711    handle_dict_types: &std::collections::HashSet<String>,
712    field_resolver: &FieldResolver,
713) {
714    let fn_name = sanitize_ident(&fixture.id);
715    let description = &fixture.description;
716    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
717    let function_name = resolve_function_name_for_call(call_config);
718    let result_var = &call_config.result_var;
719
720    let desc_with_period = if description.ends_with('.') {
721        description.to_string()
722    } else {
723        format!("{description}.")
724    };
725
726    // Emit pytest.mark.skip for fixtures that should be skipped for python.
727    if is_skipped(fixture, "python") {
728        let reason = fixture
729            .skip
730            .as_ref()
731            .and_then(|s| s.reason.as_deref())
732            .unwrap_or("skipped for python");
733        let _ = writeln!(out, "@pytest.mark.skip(reason=\"{reason}\")");
734    }
735
736    let is_async = call_config.r#async;
737    if is_async {
738        let _ = writeln!(out, "@pytest.mark.asyncio");
739        let _ = writeln!(out, "async def test_{fn_name}() -> None:");
740    } else {
741        let _ = writeln!(out, "def test_{fn_name}() -> None:");
742    }
743    let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
744
745    // Check if any assertion is an error assertion.
746    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
747
748    // Build argument expressions from config.
749    let mut arg_bindings = Vec::new();
750    let mut kwarg_exprs = Vec::new();
751    for arg in &call_config.args {
752        let var_name = &arg.name;
753
754        if arg.arg_type == "handle" {
755            // Generate a create_engine (or equivalent) call and pass the variable.
756            // If there's config data, construct a CrawlConfig with kwargs.
757            let constructor_name = format!("create_{}", arg.name.to_snake_case());
758            let config_value = resolve_field(&fixture.input, &arg.field);
759            if config_value.is_null()
760                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
761            {
762                arg_bindings.push(format!("    {var_name} = {constructor_name}(None)"));
763            } else if let Some(obj) = config_value.as_object() {
764                // Build kwargs for the config constructor (CrawlConfig(key=val, ...)).
765                // For fields with a nested type mapping, wrap the dict value in the
766                // appropriate typed constructor instead of passing a plain dict.
767                let kwargs: Vec<String> = obj
768                    .iter()
769                    .map(|(k, v)| {
770                        let snake_key = k.to_snake_case();
771                        let py_val = if let Some(type_name) = handle_nested_types.get(k) {
772                            // Wrap the nested dict in the typed constructor.
773                            if let Some(nested_obj) = v.as_object() {
774                                if nested_obj.is_empty() {
775                                    // Empty dict: use the default constructor.
776                                    format!("{type_name}()")
777                                } else if handle_dict_types.contains(k) {
778                                    // The outer Python config type (e.g. CrawlConfig) accepts a
779                                    // plain dict for this field (e.g. `auth: dict | None`).
780                                    // The binding-layer wrapper (e.g. api.py) creates the typed
781                                    // object internally, so we must NOT pre-wrap it here.
782                                    json_to_python_literal(v)
783                                } else {
784                                    // Type takes keyword arguments.
785                                    let nested_kwargs: Vec<String> = nested_obj
786                                        .iter()
787                                        .map(|(nk, nv)| {
788                                            let nested_snake_key = nk.to_snake_case();
789                                            format!("{nested_snake_key}={}", json_to_python_literal(nv))
790                                        })
791                                        .collect();
792                                    format!("{type_name}({})", nested_kwargs.join(", "))
793                                }
794                            } else {
795                                // Non-object value: use as-is.
796                                json_to_python_literal(v)
797                            }
798                        } else if k == "request_timeout" {
799                            // The Python binding converts request_timeout with Duration::from_secs
800                            // (seconds) while fixtures specify values in milliseconds. Divide by
801                            // 1000 to compensate: e.g., 1 ms → 0 s (immediate timeout),
802                            // 5000 ms → 5 s. This keeps test semantics consistent with the
803                            // fixture intent.
804                            if let Some(ms) = v.as_u64() {
805                                format!("{}", ms / 1000)
806                            } else {
807                                json_to_python_literal(v)
808                            }
809                        } else {
810                            json_to_python_literal(v)
811                        };
812                        format!("{snake_key}={py_val}")
813                    })
814                    .collect();
815                // Use the options_type if configured, otherwise "CrawlConfig".
816                let config_class = options_type.unwrap_or("CrawlConfig");
817                let single_line = format!("    {var_name}_config = {config_class}({})", kwargs.join(", "));
818                if single_line.len() <= 120 {
819                    arg_bindings.push(single_line);
820                } else {
821                    // Split into multi-line for readability and E501 compliance.
822                    let mut lines = format!("    {var_name}_config = {config_class}(\n");
823                    for kw in &kwargs {
824                        lines.push_str(&format!("        {kw},\n"));
825                    }
826                    lines.push_str("    )");
827                    arg_bindings.push(lines);
828                }
829                arg_bindings.push(format!("    {var_name} = {constructor_name}({var_name}_config)"));
830            } else {
831                let literal = json_to_python_literal(config_value);
832                arg_bindings.push(format!("    {var_name} = {constructor_name}({literal})"));
833            }
834            kwarg_exprs.push(format!("{var_name}={var_name}"));
835            continue;
836        }
837
838        if arg.arg_type == "mock_url" {
839            let fixture_id = &fixture.id;
840            arg_bindings.push(format!(
841                "    {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
842            ));
843            kwarg_exprs.push(format!("{var_name}={var_name}"));
844            continue;
845        }
846
847        let value = resolve_field(&fixture.input, &arg.field);
848
849        if value.is_null() && arg.optional {
850            continue;
851        }
852
853        // For json_object args, use the configured options_via strategy.
854        if arg.arg_type == "json_object" && !value.is_null() {
855            match options_via {
856                "dict" => {
857                    // Pass as a plain Python dict literal.
858                    let literal = json_to_python_literal(value);
859                    let noqa = if literal.contains("/tmp/") {
860                        "  # noqa: S108"
861                    } else {
862                        ""
863                    };
864                    arg_bindings.push(format!("    {var_name} = {literal}{noqa}"));
865                    kwarg_exprs.push(format!("{var_name}={var_name}"));
866                    continue;
867                }
868                "json" => {
869                    // Pass via json.loads() with the raw JSON string.
870                    let json_str = serde_json::to_string(value).unwrap_or_default();
871                    let escaped = escape_python(&json_str);
872                    arg_bindings.push(format!("    {var_name} = json.loads(\"{escaped}\")"));
873                    kwarg_exprs.push(format!("{var_name}={var_name}"));
874                    continue;
875                }
876                _ => {
877                    // "kwargs" (default): construct OptionsType(key=val, ...).
878                    if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
879                        let kwargs: Vec<String> = obj
880                            .iter()
881                            .map(|(k, v)| {
882                                let snake_key = k.to_snake_case();
883                                let py_val = if let Some(enum_type) = enum_fields.get(k) {
884                                    // Map string value to enum constant.
885                                    if let Some(s) = v.as_str() {
886                                        let upper_val = s.to_shouty_snake_case();
887                                        format!("{enum_type}.{upper_val}")
888                                    } else {
889                                        json_to_python_literal(v)
890                                    }
891                                } else {
892                                    json_to_python_literal(v)
893                                };
894                                format!("{snake_key}={py_val}")
895                            })
896                            .collect();
897                        let constructor = format!("{opts_type}({})", kwargs.join(", "));
898                        arg_bindings.push(format!("    {var_name} = {constructor}"));
899                        kwarg_exprs.push(format!("{var_name}={var_name}"));
900                        continue;
901                    }
902                }
903            }
904        }
905
906        // For required args with no fixture value, use a language-appropriate default.
907        if value.is_null() && !arg.optional {
908            let default_val = match arg.arg_type.as_str() {
909                "string" => "\"\"".to_string(),
910                "int" | "integer" => "0".to_string(),
911                "float" | "number" => "0.0".to_string(),
912                "bool" | "boolean" => "False".to_string(),
913                _ => "None".to_string(),
914            };
915            arg_bindings.push(format!("    {var_name} = {default_val}"));
916            kwarg_exprs.push(format!("{var_name}={var_name}"));
917            continue;
918        }
919
920        let literal = json_to_python_literal(value);
921        let noqa = if literal.contains("/tmp/") {
922            "  # noqa: S108"
923        } else {
924            ""
925        };
926        arg_bindings.push(format!("    {var_name} = {literal}{noqa}"));
927        kwarg_exprs.push(format!("{var_name}={var_name}"));
928    }
929
930    // Generate visitor class if the fixture has a visitor spec.
931    if let Some(visitor_spec) = &fixture.visitor {
932        let _ = writeln!(out, "    class _TestVisitor:");
933        for (method_name, action) in &visitor_spec.callbacks {
934            emit_python_visitor_method(out, method_name, action);
935        }
936        kwarg_exprs.push("visitor=_TestVisitor()".to_string());
937    }
938
939    for binding in &arg_bindings {
940        let _ = writeln!(out, "{binding}");
941    }
942
943    let call_args = kwarg_exprs.join(", ");
944    let await_prefix = if is_async { "await " } else { "" };
945    let call_expr = format!("{await_prefix}{function_name}({call_args})");
946
947    if has_error_assertion {
948        // Find error assertion for optional message check.
949        let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
950        let has_message = error_assertion
951            .and_then(|a| a.value.as_ref())
952            .and_then(|v| v.as_str())
953            .is_some();
954
955        if has_message {
956            let _ = writeln!(out, "    with pytest.raises(Exception) as exc_info:  # noqa: B017");
957            let _ = writeln!(out, "        {call_expr}");
958            if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
959                let escaped = escape_python(msg);
960                let _ = writeln!(out, "    assert \"{escaped}\" in str(exc_info.value)  # noqa: S101");
961            }
962        } else {
963            let _ = writeln!(out, "    with pytest.raises(Exception):  # noqa: B017");
964            let _ = writeln!(out, "        {call_expr}");
965        }
966
967        // Skip non-error assertions: `result` is not defined outside the
968        // `pytest.raises` block, so referencing it would trigger ruff F821.
969        return;
970    }
971
972    // Non-error path.
973    let has_usable_assertion = fixture.assertions.iter().any(|a| {
974        if a.assertion_type == "not_error" || a.assertion_type == "error" {
975            return false;
976        }
977        match &a.field {
978            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
979            _ => true,
980        }
981    });
982    let py_result_var = if has_usable_assertion {
983        result_var.to_string()
984    } else {
985        "_".to_string()
986    };
987    let _ = writeln!(out, "    {py_result_var} = {call_expr}");
988
989    let fields_enum = &e2e_config.fields_enum;
990    for assertion in &fixture.assertions {
991        if assertion.assertion_type == "not_error" {
992            // The call already raises on error in Python.
993            continue;
994        }
995        render_assertion(out, assertion, result_var, field_resolver, fields_enum);
996    }
997}
998
999// ---------------------------------------------------------------------------
1000// Argument rendering
1001// ---------------------------------------------------------------------------
1002
1003fn resolve_field<'a>(input: &'a serde_json::Value, field_path: &str) -> &'a serde_json::Value {
1004    // Field paths in call config are "input.path", "input.config", etc.
1005    // Since we already receive `fixture.input`, strip the leading "input." prefix.
1006    let path = field_path.strip_prefix("input.").unwrap_or(field_path);
1007    let mut current = input;
1008    for part in path.split('.') {
1009        current = current.get(part).unwrap_or(&serde_json::Value::Null);
1010    }
1011    current
1012}
1013
1014fn json_to_python_literal(value: &serde_json::Value) -> String {
1015    match value {
1016        serde_json::Value::Null => "None".to_string(),
1017        serde_json::Value::Bool(true) => "True".to_string(),
1018        serde_json::Value::Bool(false) => "False".to_string(),
1019        serde_json::Value::Number(n) => n.to_string(),
1020        serde_json::Value::String(s) => python_string_literal(s),
1021        serde_json::Value::Array(arr) => {
1022            let items: Vec<String> = arr.iter().map(json_to_python_literal).collect();
1023            format!("[{}]", items.join(", "))
1024        }
1025        serde_json::Value::Object(map) => {
1026            let items: Vec<String> = map
1027                .iter()
1028                .map(|(k, v)| format!("\"{}\": {}", escape_python(k), json_to_python_literal(v)))
1029                .collect();
1030            format!("{{{}}}", items.join(", "))
1031        }
1032    }
1033}
1034
1035// ---------------------------------------------------------------------------
1036// Assertion rendering
1037// ---------------------------------------------------------------------------
1038
1039fn render_assertion(
1040    out: &mut String,
1041    assertion: &Assertion,
1042    result_var: &str,
1043    field_resolver: &FieldResolver,
1044    fields_enum: &std::collections::HashSet<String>,
1045) {
1046    // Skip assertions on fields that don't exist on the result type.
1047    if let Some(f) = &assertion.field {
1048        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1049            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
1050            return;
1051        }
1052    }
1053
1054    let field_access = match &assertion.field {
1055        Some(f) if !f.is_empty() => field_resolver.accessor(f, "python", result_var),
1056        _ => result_var.to_string(),
1057    };
1058
1059    // Determine whether this field should be compared as an enum string.
1060    //
1061    // PyO3 integer-based enums (`#[pyclass(eq, eq_int)]`) are NOT iterable, so
1062    // `"value" in enum_field` raises TypeError.  Use `str(enum_field).lower()`
1063    // instead, which for a variant like `LinkType.Anchor` gives `"linktype.anchor"`,
1064    // making `"anchor" in str(LinkType.Anchor).lower()` evaluate to True.
1065    //
1066    // We apply this to fields explicitly listed in `fields_enum` (using both the
1067    // fixture field path and the resolved path) and to any field whose accessor
1068    // involves array-element indexing (`[0]`) which typically holds typed enums.
1069    let field_is_enum = assertion.field.as_deref().is_some_and(|f| {
1070        if fields_enum.contains(f) {
1071            return true;
1072        }
1073        let resolved = field_resolver.resolve(f);
1074        if fields_enum.contains(resolved) {
1075            return true;
1076        }
1077        // Also treat fields accessed via array indexing as potentially enum-typed
1078        // (e.g., `result.links[0].link_type`, `result.assets[0].asset_category`).
1079        // This is safe because `str(string_value).lower()` is idempotent for
1080        // plain string fields, and all fixture `contains` values are lowercase.
1081        field_resolver.accessor(f, "python", result_var).contains("[0]")
1082    });
1083
1084    // Check whether the field path (or any prefix of it) is optional so we can
1085    // guard `in` / `not in` expressions against None.
1086    let field_is_optional = match &assertion.field {
1087        Some(f) if !f.is_empty() => {
1088            let resolved = field_resolver.resolve(f);
1089            field_resolver.is_optional(resolved)
1090        }
1091        _ => false,
1092    };
1093
1094    match assertion.assertion_type.as_str() {
1095        "error" | "not_error" => {
1096            // Handled at call site.
1097        }
1098        "equals" => {
1099            if let Some(val) = &assertion.value {
1100                let expected = value_to_python_string(val);
1101                // Use `is` for boolean/None comparisons (ruff E712).
1102                let op = if val.is_boolean() || val.is_null() { "is" } else { "==" };
1103                // For string equality, strip trailing whitespace to handle trailing newlines
1104                // from the converter.
1105                if val.is_string() {
1106                    let _ = writeln!(out, "    assert {field_access}.strip() {op} {expected}  # noqa: S101");
1107                } else {
1108                    let _ = writeln!(out, "    assert {field_access} {op} {expected}  # noqa: S101");
1109                }
1110            }
1111        }
1112        "contains" => {
1113            if let Some(val) = &assertion.value {
1114                let expected = value_to_python_string(val);
1115                // For enum fields, convert to lowercase string for comparison.
1116                let cmp_expr = if field_is_enum && val.is_string() {
1117                    format!("str({field_access}).lower()")
1118                } else {
1119                    field_access.clone()
1120                };
1121                if field_is_optional {
1122                    let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
1123                    let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
1124                } else {
1125                    let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
1126                }
1127            }
1128        }
1129        "contains_all" => {
1130            if let Some(values) = &assertion.values {
1131                for val in values {
1132                    let expected = value_to_python_string(val);
1133                    // For enum fields, convert to lowercase string for comparison.
1134                    let cmp_expr = if field_is_enum && val.is_string() {
1135                        format!("str({field_access}).lower()")
1136                    } else {
1137                        field_access.clone()
1138                    };
1139                    if field_is_optional {
1140                        let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
1141                        let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
1142                    } else {
1143                        let _ = writeln!(out, "    assert {expected} in {cmp_expr}  # noqa: S101");
1144                    }
1145                }
1146            }
1147        }
1148        "not_contains" => {
1149            if let Some(val) = &assertion.value {
1150                let expected = value_to_python_string(val);
1151                // For enum fields, convert to lowercase string for comparison.
1152                let cmp_expr = if field_is_enum && val.is_string() {
1153                    format!("str({field_access}).lower()")
1154                } else {
1155                    field_access.clone()
1156                };
1157                if field_is_optional {
1158                    let _ = writeln!(
1159                        out,
1160                        "    assert {field_access} is None or {expected} not in {cmp_expr}  # noqa: S101"
1161                    );
1162                } else {
1163                    let _ = writeln!(out, "    assert {expected} not in {cmp_expr}  # noqa: S101");
1164                }
1165            }
1166        }
1167        "not_empty" => {
1168            let _ = writeln!(out, "    assert {field_access}  # noqa: S101");
1169        }
1170        "is_empty" => {
1171            let _ = writeln!(out, "    assert not {field_access}  # noqa: S101");
1172        }
1173        "contains_any" => {
1174            if let Some(values) = &assertion.values {
1175                let items: Vec<String> = values.iter().map(value_to_python_string).collect();
1176                let list_str = items.join(", ");
1177                // For enum fields, convert to lowercase string for comparison.
1178                let cmp_expr = if field_is_enum {
1179                    format!("str({field_access}).lower()")
1180                } else {
1181                    field_access.clone()
1182                };
1183                if field_is_optional {
1184                    let _ = writeln!(out, "    assert {field_access} is not None  # noqa: S101");
1185                    let _ = writeln!(
1186                        out,
1187                        "    assert any(v in {cmp_expr} for v in [{list_str}])  # noqa: S101"
1188                    );
1189                } else {
1190                    let _ = writeln!(
1191                        out,
1192                        "    assert any(v in {cmp_expr} for v in [{list_str}])  # noqa: S101"
1193                    );
1194                }
1195            }
1196        }
1197        "greater_than" => {
1198            if let Some(val) = &assertion.value {
1199                let expected = value_to_python_string(val);
1200                let _ = writeln!(out, "    assert {field_access} > {expected}  # noqa: S101");
1201            }
1202        }
1203        "less_than" => {
1204            if let Some(val) = &assertion.value {
1205                let expected = value_to_python_string(val);
1206                let _ = writeln!(out, "    assert {field_access} < {expected}  # noqa: S101");
1207            }
1208        }
1209        "greater_than_or_equal" | "min" => {
1210            if let Some(val) = &assertion.value {
1211                let expected = value_to_python_string(val);
1212                let _ = writeln!(out, "    assert {field_access} >= {expected}  # noqa: S101");
1213            }
1214        }
1215        "less_than_or_equal" | "max" => {
1216            if let Some(val) = &assertion.value {
1217                let expected = value_to_python_string(val);
1218                let _ = writeln!(out, "    assert {field_access} <= {expected}  # noqa: S101");
1219            }
1220        }
1221        "starts_with" => {
1222            if let Some(val) = &assertion.value {
1223                let expected = value_to_python_string(val);
1224                let _ = writeln!(out, "    assert {field_access}.startswith({expected})  # noqa: S101");
1225            }
1226        }
1227        "ends_with" => {
1228            if let Some(val) = &assertion.value {
1229                let expected = value_to_python_string(val);
1230                let _ = writeln!(out, "    assert {field_access}.endswith({expected})  # noqa: S101");
1231            }
1232        }
1233        "min_length" => {
1234            if let Some(val) = &assertion.value {
1235                if let Some(n) = val.as_u64() {
1236                    let _ = writeln!(out, "    assert len({field_access}) >= {n}  # noqa: S101");
1237                }
1238            }
1239        }
1240        "max_length" => {
1241            if let Some(val) = &assertion.value {
1242                if let Some(n) = val.as_u64() {
1243                    let _ = writeln!(out, "    assert len({field_access}) <= {n}  # noqa: S101");
1244                }
1245            }
1246        }
1247        "count_min" => {
1248            if let Some(val) = &assertion.value {
1249                if let Some(n) = val.as_u64() {
1250                    let _ = writeln!(out, "    assert len({field_access}) >= {n}  # noqa: S101");
1251                }
1252            }
1253        }
1254        "count_equals" => {
1255            if let Some(val) = &assertion.value {
1256                if let Some(n) = val.as_u64() {
1257                    let _ = writeln!(out, "    assert len({field_access}) == {n}  # noqa: S101");
1258                }
1259            }
1260        }
1261        "is_true" => {
1262            let _ = writeln!(out, "    assert {field_access} is True  # noqa: S101");
1263        }
1264        "is_false" => {
1265            let _ = writeln!(out, "    assert not {field_access}  # noqa: S101");
1266        }
1267        "method_result" => {
1268            if let Some(method_name) = &assertion.method {
1269                let call_expr = build_python_method_call(result_var, method_name, assertion.args.as_ref());
1270                let check = assertion.check.as_deref().unwrap_or("is_true");
1271                match check {
1272                    "equals" => {
1273                        if let Some(val) = &assertion.value {
1274                            if val.is_boolean() {
1275                                if val.as_bool() == Some(true) {
1276                                    let _ = writeln!(out, "    assert {call_expr} is True  # noqa: S101");
1277                                } else {
1278                                    let _ = writeln!(out, "    assert {call_expr} is False  # noqa: S101");
1279                                }
1280                            } else {
1281                                let expected = value_to_python_string(val);
1282                                let _ = writeln!(out, "    assert {call_expr} == {expected}  # noqa: S101");
1283                            }
1284                        }
1285                    }
1286                    "is_true" => {
1287                        let _ = writeln!(out, "    assert {call_expr}  # noqa: S101");
1288                    }
1289                    "is_false" => {
1290                        let _ = writeln!(out, "    assert not {call_expr}  # noqa: S101");
1291                    }
1292                    "greater_than_or_equal" => {
1293                        if let Some(val) = &assertion.value {
1294                            let n = val.as_u64().unwrap_or(0);
1295                            let _ = writeln!(out, "    assert {call_expr} >= {n}  # noqa: S101");
1296                        }
1297                    }
1298                    "count_min" => {
1299                        if let Some(val) = &assertion.value {
1300                            let n = val.as_u64().unwrap_or(0);
1301                            let _ = writeln!(out, "    assert len({call_expr}) >= {n}  # noqa: S101");
1302                        }
1303                    }
1304                    "contains" => {
1305                        if let Some(val) = &assertion.value {
1306                            let expected = value_to_python_string(val);
1307                            let _ = writeln!(out, "    assert {expected} in {call_expr}  # noqa: S101");
1308                        }
1309                    }
1310                    "is_error" => {
1311                        let _ = writeln!(out, "    with pytest.raises(Exception):  # noqa: B017");
1312                        let _ = writeln!(out, "        {call_expr}");
1313                    }
1314                    other_check => {
1315                        panic!("unsupported method_result check type: {other_check}");
1316                    }
1317                }
1318            } else {
1319                panic!("method_result assertion missing 'method' field");
1320            }
1321        }
1322        "matches_regex" => {
1323            if let Some(val) = &assertion.value {
1324                let expected = value_to_python_string(val);
1325                let _ = writeln!(out, "    import re  # noqa: PLC0415");
1326                let _ = writeln!(
1327                    out,
1328                    "    assert re.search({expected}, {field_access}) is not None  # noqa: S101"
1329                );
1330            }
1331        }
1332        other => {
1333            panic!("unsupported assertion type: {other}");
1334        }
1335    }
1336}
1337
1338/// Build a Python call expression for a method_result assertion on a tree-sitter Tree.
1339/// Maps method names to the appropriate Python function calls.
1340fn build_python_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1341    match method_name {
1342        "root_child_count" => format!("{result_var}.root_node().child_count()"),
1343        "root_node_type" => format!("{result_var}.root_node().kind()"),
1344        "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1345        "has_error_nodes" => format!("tree_has_error_nodes({result_var})"),
1346        "error_count" | "tree_error_count" => format!("tree_error_count({result_var})"),
1347        "tree_to_sexp" => format!("tree_to_sexp({result_var})"),
1348        "contains_node_type" => {
1349            let node_type = args
1350                .and_then(|a| a.get("node_type"))
1351                .and_then(|v| v.as_str())
1352                .unwrap_or("");
1353            format!("tree_contains_node_type({result_var}, \"{node_type}\")")
1354        }
1355        "find_nodes_by_type" => {
1356            let node_type = args
1357                .and_then(|a| a.get("node_type"))
1358                .and_then(|v| v.as_str())
1359                .unwrap_or("");
1360            format!("find_nodes_by_type({result_var}, \"{node_type}\")")
1361        }
1362        "run_query" => {
1363            let query_source = args
1364                .and_then(|a| a.get("query_source"))
1365                .and_then(|v| v.as_str())
1366                .unwrap_or("");
1367            let language = args
1368                .and_then(|a| a.get("language"))
1369                .and_then(|v| v.as_str())
1370                .unwrap_or("");
1371            format!("run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1372        }
1373        _ => {
1374            if let Some(args_val) = args {
1375                let arg_str = args_val
1376                    .as_object()
1377                    .map(|obj| {
1378                        obj.iter()
1379                            .map(|(k, v)| format!("{}={}", k, value_to_python_string(v)))
1380                            .collect::<Vec<_>>()
1381                            .join(", ")
1382                    })
1383                    .unwrap_or_default();
1384                format!("{result_var}.{method_name}({arg_str})")
1385            } else {
1386                format!("{result_var}.{method_name}()")
1387            }
1388        }
1389    }
1390}
1391
1392/// Returns the Python import name for a method_result method that uses a
1393/// module-level helper function (not a method on the result object).
1394fn python_method_helper_import(method_name: &str) -> Option<String> {
1395    match method_name {
1396        "has_error_nodes" => Some("tree_has_error_nodes".to_string()),
1397        "error_count" | "tree_error_count" => Some("tree_error_count".to_string()),
1398        "tree_to_sexp" => Some("tree_to_sexp".to_string()),
1399        "contains_node_type" => Some("tree_contains_node_type".to_string()),
1400        "find_nodes_by_type" => Some("find_nodes_by_type".to_string()),
1401        "run_query" => Some("run_query".to_string()),
1402        // Methods accessed via result_var (e.g. tree.root_node().child_count()) don't need imports.
1403        _ => None,
1404    }
1405}
1406
1407fn value_to_python_string(value: &serde_json::Value) -> String {
1408    match value {
1409        serde_json::Value::String(s) => python_string_literal(s),
1410        serde_json::Value::Bool(true) => "True".to_string(),
1411        serde_json::Value::Bool(false) => "False".to_string(),
1412        serde_json::Value::Number(n) => n.to_string(),
1413        serde_json::Value::Null => "None".to_string(),
1414        other => python_string_literal(&other.to_string()),
1415    }
1416}
1417
1418/// Produce a quoted Python string literal, choosing single or double quotes
1419/// to avoid unnecessary escaping (ruff Q003).
1420fn python_string_literal(s: &str) -> String {
1421    if s.contains('"') && !s.contains('\'') {
1422        // Use single quotes to avoid escaping double quotes.
1423        let escaped = s
1424            .replace('\\', "\\\\")
1425            .replace('\'', "\\'")
1426            .replace('\n', "\\n")
1427            .replace('\r', "\\r")
1428            .replace('\t', "\\t");
1429        format!("'{escaped}'")
1430    } else {
1431        format!("\"{}\"", escape_python(s))
1432    }
1433}
1434
1435/// Emit a Python visitor method for a callback action.
1436fn emit_python_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1437    let params = match method_name {
1438        "visit_link" => "self, ctx, href, text, title",
1439        "visit_image" => "self, ctx, src, alt, title",
1440        "visit_heading" => "self, ctx, level, text, id",
1441        "visit_code_block" => "self, ctx, lang, code",
1442        "visit_code_inline"
1443        | "visit_strong"
1444        | "visit_emphasis"
1445        | "visit_strikethrough"
1446        | "visit_underline"
1447        | "visit_subscript"
1448        | "visit_superscript"
1449        | "visit_mark"
1450        | "visit_button"
1451        | "visit_summary"
1452        | "visit_figcaption"
1453        | "visit_definition_term"
1454        | "visit_definition_description" => "self, ctx, text",
1455        "visit_text" => "self, ctx, text",
1456        "visit_list_item" => "self, ctx, ordered, marker, text",
1457        "visit_blockquote" => "self, ctx, content, depth",
1458        "visit_table_row" => "self, ctx, cells, is_header",
1459        "visit_custom_element" => "self, ctx, tag_name, html",
1460        "visit_form" => "self, ctx, action_url, method",
1461        "visit_input" => "self, ctx, input_type, name, value",
1462        "visit_audio" | "visit_video" | "visit_iframe" => "self, ctx, src",
1463        "visit_details" => "self, ctx, is_open",
1464        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1465            "self, ctx, output, *args"
1466        }
1467        "visit_list_start" => "self, ctx, ordered, *args",
1468        "visit_list_end" => "self, ctx, ordered, output, *args",
1469        _ => "self, ctx, *args",
1470    };
1471
1472    let _ = writeln!(
1473        out,
1474        "        def {method_name}({params}):  # noqa: A002, ANN001, ANN202, ARG002"
1475    );
1476    match action {
1477        CallbackAction::Skip => {
1478            let _ = writeln!(out, "            return \"skip\"");
1479        }
1480        CallbackAction::Continue => {
1481            let _ = writeln!(out, "            return \"continue\"");
1482        }
1483        CallbackAction::PreserveHtml => {
1484            let _ = writeln!(out, "            return \"preserve_html\"");
1485        }
1486        CallbackAction::Custom { output } => {
1487            let escaped = escape_python(output);
1488            let _ = writeln!(out, "            return {{\"custom\": \"{escaped}\"}}");
1489        }
1490        CallbackAction::CustomTemplate { template } => {
1491            // Use single-quoted f-string so that double quotes inside the template
1492            // (e.g. `QUOTE: "{text}"`) are not misinterpreted as string delimiters.
1493            // Escape newlines/tabs/backslashes/single quotes so the template stays
1494            // on a single line in the generated source.
1495            let escaped_template = template
1496                .replace('\\', "\\\\")
1497                .replace('\'', "\\'")
1498                .replace('\n', "\\n")
1499                .replace('\r', "\\r")
1500                .replace('\t', "\\t");
1501            let _ = writeln!(out, "            return {{\"custom\": f'{escaped_template}'}}");
1502        }
1503    }
1504}