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