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            // Skip emitting the file entirely when every fixture is skipped for
83            // python — there's nothing to run, and emitting imports of
84            // not-bound APIs causes module-level ImportError that masks the
85            // skip marker.
86            if fixtures.iter().all(|f| is_skipped(f, "python")) {
87                continue;
88            }
89
90            let filename = format!("test_{}.py", sanitize_filename(&group.category));
91            let content = render_test_file(&group.category, &fixtures, e2e_config);
92
93            files.push(GeneratedFile {
94                path: output_base.join("tests").join(filename),
95                content,
96                generated_header: true,
97            });
98        }
99
100        Ok(files)
101    }
102
103    fn language_name(&self) -> &'static str {
104        "python"
105    }
106}
107
108// ---------------------------------------------------------------------------
109// pyproject.toml
110// ---------------------------------------------------------------------------
111
112fn render_pyproject(
113    pkg_name: &str,
114    _pkg_path: &str,
115    pkg_version: &str,
116    dep_mode: crate::config::DependencyMode,
117) -> String {
118    let dep_spec = match dep_mode {
119        crate::config::DependencyMode::Registry => {
120            format!(
121                "dependencies = [\"{pkg_name}{pkg_version}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n"
122            )
123        }
124        crate::config::DependencyMode::Local => {
125            format!(
126                "dependencies = [\"{pkg_name}\", \"pytest>=7.4\", \"pytest-asyncio>=0.23\", \"pytest-timeout>=2.1\"]\n\
127                 \n\
128                 [tool.uv.sources]\n\
129                 {pkg_name} = {{ workspace = true }}\n"
130            )
131        }
132    };
133
134    format!(
135        r#"[build-system]
136build-backend = "setuptools.build_meta"
137requires = ["setuptools>=68", "wheel"]
138
139[project]
140name = "{pkg_name}-e2e-tests"
141version = "0.0.0"
142description = "End-to-end tests"
143requires-python = ">=3.10"
144{dep_spec}
145[tool.setuptools]
146packages = []
147
148[tool.pytest.ini_options]
149asyncio_mode = "auto"
150testpaths = ["tests"]
151python_files = "test_*.py"
152python_functions = "test_*"
153addopts = "-v --strict-markers --tb=short"
154timeout = 300
155
156[tool.ruff.lint]
157ignore = ["PLR2004"]
158
159[tool.ruff.lint.per-file-ignores]
160"tests/**" = ["S101", "S108", "PT011", "B017"]
161"#
162    )
163}
164
165// ---------------------------------------------------------------------------
166// Config resolution helpers
167// ---------------------------------------------------------------------------
168
169fn resolve_function_name(e2e_config: &E2eConfig) -> String {
170    resolve_function_name_for_call(&e2e_config.call)
171}
172
173fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
174    call_config
175        .overrides
176        .get("python")
177        .and_then(|o| o.function.clone())
178        .unwrap_or_else(|| call_config.function.clone())
179}
180
181fn resolve_module(e2e_config: &E2eConfig) -> String {
182    e2e_config
183        .call
184        .overrides
185        .get("python")
186        .and_then(|o| o.module.clone())
187        .unwrap_or_else(|| e2e_config.call.module.replace('-', "_"))
188}
189
190fn resolve_options_type(e2e_config: &E2eConfig) -> Option<String> {
191    e2e_config
192        .call
193        .overrides
194        .get("python")
195        .and_then(|o| o.options_type.clone())
196}
197
198/// Resolve how json_object args are passed: "kwargs" (default), "dict", or "json".
199fn resolve_options_via(e2e_config: &E2eConfig) -> &str {
200    e2e_config
201        .call
202        .overrides
203        .get("python")
204        .and_then(|o| o.options_via.as_deref())
205        .unwrap_or("kwargs")
206}
207
208/// Resolve enum field mappings from the Python override config.
209fn resolve_enum_fields(e2e_config: &E2eConfig) -> &HashMap<String, String> {
210    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
211    e2e_config
212        .call
213        .overrides
214        .get("python")
215        .map(|o| &o.enum_fields)
216        .unwrap_or(&EMPTY)
217}
218
219/// Resolve handle nested type mappings from the Python override config.
220/// Maps config field names to their Python constructor type names.
221fn resolve_handle_nested_types(e2e_config: &E2eConfig) -> &HashMap<String, String> {
222    static EMPTY: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
223    e2e_config
224        .call
225        .overrides
226        .get("python")
227        .map(|o| &o.handle_nested_types)
228        .unwrap_or(&EMPTY)
229}
230
231/// Resolve handle dict type set from the Python override config.
232/// Fields in this set use `TypeName({...})` instead of `TypeName(key=val, ...)`.
233fn resolve_handle_dict_types(e2e_config: &E2eConfig) -> &std::collections::HashSet<String> {
234    static EMPTY: std::sync::LazyLock<std::collections::HashSet<String>> =
235        std::sync::LazyLock::new(std::collections::HashSet::new);
236    e2e_config
237        .call
238        .overrides
239        .get("python")
240        .map(|o| &o.handle_dict_types)
241        .unwrap_or(&EMPTY)
242}
243
244fn is_skipped(fixture: &Fixture, language: &str) -> bool {
245    fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
246}
247
248// ---------------------------------------------------------------------------
249// Rendering
250// ---------------------------------------------------------------------------
251
252fn render_conftest(e2e_config: &E2eConfig, groups: &[FixtureGroup]) -> String {
253    let module = resolve_module(e2e_config);
254    let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
255
256    let header = hash::header(CommentStyle::Hash);
257    if has_http_fixtures {
258        format!(
259            r#"{header}"""Pytest configuration for e2e tests."""
260import pytest
261
262# Ensure the package is importable.
263# The {module} package is expected to be installed in the current environment.
264
265
266@pytest.fixture
267def client(http_test_server):  # noqa: ANN001, ANN201
268    """Return a test client bound to the per-test HTTP server."""
269    return http_test_server.client()
270"#
271        )
272    } else {
273        format!(
274            r#"{header}"""Pytest configuration for e2e tests."""
275# Ensure the package is importable.
276# The {module} package is expected to be installed in the current environment.
277"#
278        )
279    }
280}
281
282fn render_test_file(category: &str, fixtures: &[&Fixture], e2e_config: &E2eConfig) -> String {
283    let mut out = String::new();
284    out.push_str(&hash::header(CommentStyle::Hash));
285    let _ = writeln!(out, "\"\"\"E2e tests for category: {category}.\"\"\"");
286
287    let module = resolve_module(e2e_config);
288    let function_name = resolve_function_name(e2e_config);
289    let options_type = resolve_options_type(e2e_config);
290    let options_via = resolve_options_via(e2e_config);
291    let enum_fields = resolve_enum_fields(e2e_config);
292    let handle_nested_types = resolve_handle_nested_types(e2e_config);
293    let handle_dict_types = resolve_handle_dict_types(e2e_config);
294    let field_resolver = FieldResolver::new(
295        &e2e_config.fields,
296        &e2e_config.fields_optional,
297        &e2e_config.result_fields,
298        &e2e_config.fields_array,
299    );
300
301    let has_error_test = fixtures
302        .iter()
303        .any(|f| f.assertions.iter().any(|a| a.assertion_type == "error"));
304    let has_skipped = fixtures.iter().any(|f| is_skipped(f, "python"));
305    let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
306
307    // Check if any fixture in this file uses an async call.
308    let is_async = fixtures.iter().any(|f| {
309        let cc = e2e_config.resolve_call(f.call.as_deref());
310        cc.r#async
311    }) || e2e_config.call.r#async;
312    let needs_pytest = has_error_test || has_skipped || is_async;
313
314    // "json" mode needs `import json`.
315    let needs_json_import = options_via == "json"
316        && fixtures.iter().any(|f| {
317            e2e_config
318                .call
319                .args
320                .iter()
321                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
322        });
323
324    // mock_url args need `import os`.
325    let needs_os_import = e2e_config.call.args.iter().any(|arg| arg.arg_type == "mock_url");
326
327    // HTTP tests with header UUID assertions need `import re`.
328    let needs_re_import = has_http_tests
329        && fixtures.iter().any(|f| {
330            f.http
331                .as_ref()
332                .is_some_and(|h| h.expected_response.headers.values().any(|v| v == "<<uuid>>"))
333        });
334
335    // Only import options_type when using "kwargs" mode.
336    let needs_options_type = options_via == "kwargs"
337        && options_type.is_some()
338        && fixtures.iter().any(|f| {
339            e2e_config
340                .call
341                .args
342                .iter()
343                .any(|arg| arg.arg_type == "json_object" && !resolve_field(&f.input, &arg.field).is_null())
344        });
345
346    // Collect enum types actually used across all fixtures in this file.
347    let mut used_enum_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
348    if needs_options_type && !enum_fields.is_empty() {
349        for fixture in fixtures.iter() {
350            for arg in &e2e_config.call.args {
351                if arg.arg_type == "json_object" {
352                    let value = resolve_field(&fixture.input, &arg.field);
353                    if let Some(obj) = value.as_object() {
354                        for key in obj.keys() {
355                            if let Some(enum_type) = enum_fields.get(key) {
356                                used_enum_types.insert(enum_type.clone());
357                            }
358                        }
359                    }
360                }
361            }
362        }
363    }
364
365    // Collect imports sorted per isort/ruff I001: stdlib group, then
366    // third-party group, separated by a blank line. Within each group
367    // `import X` lines come before `from X import Y` lines, both sorted.
368    let mut stdlib_imports: Vec<String> = Vec::new();
369    let mut thirdparty_bare: Vec<String> = Vec::new();
370    let mut thirdparty_from: Vec<String> = Vec::new();
371
372    if needs_json_import {
373        stdlib_imports.push("import json".to_string());
374    }
375
376    if needs_os_import {
377        stdlib_imports.push("import os".to_string());
378    }
379
380    if needs_re_import {
381        stdlib_imports.push("import re".to_string());
382    }
383
384    if needs_pytest {
385        thirdparty_bare.push("import pytest".to_string());
386    }
387
388    // For non-HTTP fixtures, build the normal function imports.
389    let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test());
390    if has_non_http_fixtures {
391        // Collect handle constructor function names that need to be imported.
392        let handle_constructors: Vec<String> = e2e_config
393            .call
394            .args
395            .iter()
396            .filter(|arg| arg.arg_type == "handle")
397            .map(|arg| format!("create_{}", arg.name.to_snake_case()))
398            .collect();
399
400        // Collect all unique function names actually used across all fixtures in this file.
401        // Do not seed with the default function_name — only include it when at least one
402        // fixture resolves to it, to avoid unused-import (F401) warnings from ruff.
403        let mut import_names: Vec<String> = Vec::new();
404        for fixture in fixtures.iter() {
405            let cc = e2e_config.resolve_call(fixture.call.as_deref());
406            let fn_name = resolve_function_name_for_call(cc);
407            if !import_names.contains(&fn_name) {
408                import_names.push(fn_name);
409            }
410        }
411        // Safety net: should not occur since the group is non-empty, but ensures
412        // import_names is never empty if all fixtures use the default call.
413        if import_names.is_empty() {
414            import_names.push(function_name.clone());
415        }
416        for ctor in &handle_constructors {
417            if !import_names.contains(ctor) {
418                import_names.push(ctor.clone());
419            }
420        }
421
422        // If any handle arg has config, import the config class (CrawlConfig or options_type).
423        let needs_config_import = e2e_config.call.args.iter().any(|arg| {
424            arg.arg_type == "handle"
425                && fixtures.iter().any(|f| {
426                    let val = resolve_field(&f.input, &arg.field);
427                    !val.is_null() && val.as_object().is_some_and(|o| !o.is_empty())
428                })
429        });
430        if needs_config_import {
431            let config_class = options_type.as_deref().unwrap_or("CrawlConfig");
432            if !import_names.contains(&config_class.to_string()) {
433                import_names.push(config_class.to_string());
434            }
435        }
436
437        // Import any nested handle config types actually used in this file.
438        if !handle_nested_types.is_empty() {
439            let mut used_nested_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
440            for fixture in fixtures.iter() {
441                for arg in &e2e_config.call.args {
442                    if arg.arg_type == "handle" {
443                        let config_value = resolve_field(&fixture.input, &arg.field);
444                        if let Some(obj) = config_value.as_object() {
445                            for key in obj.keys() {
446                                if let Some(type_name) = handle_nested_types.get(key) {
447                                    if obj[key].is_object() {
448                                        used_nested_types.insert(type_name.clone());
449                                    }
450                                }
451                            }
452                        }
453                    }
454                }
455            }
456            for type_name in used_nested_types {
457                if !import_names.contains(&type_name) {
458                    import_names.push(type_name);
459                }
460            }
461        }
462
463        // Collect method_result helper function imports.
464        for fixture in fixtures.iter() {
465            for assertion in &fixture.assertions {
466                if assertion.assertion_type == "method_result" {
467                    if let Some(method_name) = &assertion.method {
468                        let import = python_method_helper_import(method_name);
469                        if let Some(name) = import {
470                            if !import_names.contains(&name) {
471                                import_names.push(name);
472                            }
473                        }
474                    }
475                }
476            }
477        }
478
479        if let (true, Some(opts_type)) = (needs_options_type, &options_type) {
480            import_names.push(opts_type.clone());
481            thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
482            // Import enum types from enum_module (if specified) or main module.
483            if !used_enum_types.is_empty() {
484                let enum_mod = e2e_config
485                    .call
486                    .overrides
487                    .get("python")
488                    .and_then(|o| o.enum_module.as_deref())
489                    .unwrap_or(&module);
490                let enum_names: Vec<&String> = used_enum_types.iter().collect();
491                thirdparty_from.push(format!(
492                    "from {enum_mod} import {}",
493                    enum_names.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
494                ));
495            }
496        } else {
497            thirdparty_from.push(format!("from {module} import {}", import_names.join(", ")));
498        }
499    }
500
501    stdlib_imports.sort();
502    thirdparty_bare.sort();
503    thirdparty_from.sort();
504
505    // Emit sorted import groups with blank lines between groups per PEP 8.
506    if !stdlib_imports.is_empty() {
507        for imp in &stdlib_imports {
508            let _ = writeln!(out, "{imp}");
509        }
510        let _ = writeln!(out);
511    }
512    // Third-party: bare imports then from-imports, no blank line between them.
513    for imp in &thirdparty_bare {
514        let _ = writeln!(out, "{imp}");
515    }
516    for imp in &thirdparty_from {
517        let _ = writeln!(out, "{imp}");
518    }
519    // Two blank lines after imports (PEP 8 / ruff I001).
520    let _ = writeln!(out);
521    let _ = writeln!(out);
522
523    for fixture in fixtures {
524        if fixture.is_http_test() {
525            render_http_test_function(&mut out, fixture);
526        } else {
527            render_test_function(
528                &mut out,
529                fixture,
530                e2e_config,
531                options_type.as_deref(),
532                options_via,
533                enum_fields,
534                handle_nested_types,
535                handle_dict_types,
536                &field_resolver,
537            );
538        }
539        let _ = writeln!(out);
540    }
541
542    out
543}
544
545// ---------------------------------------------------------------------------
546// HTTP server test rendering
547// ---------------------------------------------------------------------------
548
549/// Render a pytest test function for an HTTP server fixture.
550///
551/// The generated test:
552/// 1. Receives a `client` fixture from conftest.py (the test server client).
553/// 2. Sends the configured request.
554/// 3. Asserts status code, body (exact or partial), headers, and validation errors.
555fn render_http_test_function(out: &mut String, fixture: &Fixture) {
556    let Some(http) = &fixture.http else {
557        return;
558    };
559
560    let fn_name = sanitize_ident(&fixture.id);
561    let description = &fixture.description;
562    let desc_with_period = if description.ends_with('.') {
563        description.to_string()
564    } else {
565        format!("{description}.")
566    };
567
568    if is_skipped(fixture, "python") {
569        let reason = fixture
570            .skip
571            .as_ref()
572            .and_then(|s| s.reason.as_deref())
573            .unwrap_or("skipped for python");
574        let escaped = escape_python(reason);
575        let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
576    }
577
578    let _ = writeln!(out, "def test_{fn_name}(client) -> None:");
579    let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
580
581    // Build the request call.
582    let method = http.request.method.to_lowercase();
583    let path = &http.request.path;
584
585    // Collect keyword arguments for the request method call.
586    let mut call_kwargs: Vec<String> = Vec::new();
587
588    // JSON body
589    if let Some(body) = &http.request.body {
590        let py_body = json_to_python_literal(body);
591        call_kwargs.push(format!("        json={py_body},"));
592    }
593
594    // Request headers
595    if !http.request.headers.is_empty() {
596        let entries: Vec<String> = http
597            .request
598            .headers
599            .iter()
600            .map(|(k, v)| format!("            \"{}\": \"{}\",", escape_python(k), escape_python(v)))
601            .collect();
602        let headers_block = entries.join("\n");
603        call_kwargs.push(format!("        headers={{\n{headers_block}\n        }},"));
604    }
605
606    // Query params
607    if !http.request.query_params.is_empty() {
608        let entries: Vec<String> = http
609            .request
610            .query_params
611            .iter()
612            .map(|(k, v)| format!("            \"{}\": {},", escape_python(k), json_to_python_literal(v)))
613            .collect();
614        let params_block = entries.join("\n");
615        call_kwargs.push(format!("        params={{\n{params_block}\n        }},"));
616    }
617
618    // Cookies
619    if !http.request.cookies.is_empty() {
620        let entries: Vec<String> = http
621            .request
622            .cookies
623            .iter()
624            .map(|(k, v)| format!("            \"{}\": \"{}\",", escape_python(k), escape_python(v)))
625            .collect();
626        let cookies_block = entries.join("\n");
627        call_kwargs.push(format!("        cookies={{\n{cookies_block}\n        }},"));
628    }
629
630    if call_kwargs.is_empty() {
631        let _ = writeln!(out, "    response = client.{method}(\"{path}\")");
632    } else {
633        let _ = writeln!(out, "    response = client.{method}(");
634        let _ = writeln!(out, "        \"{path}\",");
635        for kwarg in &call_kwargs {
636            let _ = writeln!(out, "{kwarg}");
637        }
638        let _ = writeln!(out, "    )");
639    }
640
641    // Status code assertion.
642    let status = http.expected_response.status_code;
643    let _ = writeln!(out, "    assert response.status_code == {status}  # noqa: S101");
644
645    // Body assertions.
646    if let Some(expected_body) = &http.expected_response.body {
647        let py_val = json_to_python_literal(expected_body);
648        let _ = writeln!(out, "    data = response.json()");
649        let _ = writeln!(out, "    assert data == {py_val}  # noqa: S101");
650    } else if let Some(partial) = &http.expected_response.body_partial {
651        let _ = writeln!(out, "    data = response.json()");
652        if let Some(obj) = partial.as_object() {
653            for (key, val) in obj {
654                let py_val = json_to_python_literal(val);
655                let escaped_key = escape_python(key);
656                let _ = writeln!(out, "    assert data[\"{escaped_key}\"] == {py_val}  # noqa: S101");
657            }
658        }
659    }
660
661    // Header assertions.
662    for (header_name, header_value) in &http.expected_response.headers {
663        let lower_name = header_name.to_lowercase();
664        let escaped_name = escape_python(&lower_name);
665        match header_value.as_str() {
666            "<<present>>" => {
667                let _ = writeln!(out, "    assert \"{escaped_name}\" in response.headers  # noqa: S101");
668            }
669            "<<absent>>" => {
670                let _ = writeln!(
671                    out,
672                    "    assert response.headers.get(\"{escaped_name}\") is None  # noqa: S101"
673                );
674            }
675            "<<uuid>>" => {
676                let _ = writeln!(
677                    out,
678                    "    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"
679                );
680            }
681            exact => {
682                let escaped_val = escape_python(exact);
683                let _ = writeln!(
684                    out,
685                    "    assert response.headers[\"{escaped_name}\"] == \"{escaped_val}\"  # noqa: S101"
686                );
687            }
688        }
689    }
690
691    // Validation error assertions.
692    if let Some(validation_errors) = &http.expected_response.validation_errors {
693        if !validation_errors.is_empty() {
694            let _ = writeln!(out, "    errors = response.json().get(\"detail\", [])");
695            for ve in validation_errors {
696                let loc_py: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_python(s))).collect();
697                let loc_str = loc_py.join(", ");
698                let escaped_msg = escape_python(&ve.msg);
699                let _ = writeln!(
700                    out,
701                    "    assert any(e[\"loc\"] == [{loc_str}] and \"{escaped_msg}\" in e[\"msg\"] for e in errors)  # noqa: S101"
702                );
703            }
704        }
705    }
706}
707
708// ---------------------------------------------------------------------------
709// Function-call test rendering
710// ---------------------------------------------------------------------------
711
712#[allow(clippy::too_many_arguments)]
713fn render_test_function(
714    out: &mut String,
715    fixture: &Fixture,
716    e2e_config: &E2eConfig,
717    options_type: Option<&str>,
718    options_via: &str,
719    enum_fields: &HashMap<String, String>,
720    handle_nested_types: &HashMap<String, String>,
721    handle_dict_types: &std::collections::HashSet<String>,
722    field_resolver: &FieldResolver,
723) {
724    let fn_name = sanitize_ident(&fixture.id);
725    let description = &fixture.description;
726    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
727    let function_name = resolve_function_name_for_call(call_config);
728    let result_var = &call_config.result_var;
729
730    let desc_with_period = if description.ends_with('.') {
731        description.to_string()
732    } else {
733        format!("{description}.")
734    };
735
736    // Emit pytest.mark.skip for fixtures that should be skipped for python.
737    if is_skipped(fixture, "python") {
738        let reason = fixture
739            .skip
740            .as_ref()
741            .and_then(|s| s.reason.as_deref())
742            .unwrap_or("skipped for python");
743        let escaped = escape_python(reason);
744        let _ = writeln!(out, "@pytest.mark.skip(reason=\"{escaped}\")");
745    }
746
747    let is_async = call_config.r#async;
748    if is_async {
749        let _ = writeln!(out, "@pytest.mark.asyncio");
750        let _ = writeln!(out, "async def test_{fn_name}() -> None:");
751    } else {
752        let _ = writeln!(out, "def test_{fn_name}() -> None:");
753    }
754    let _ = writeln!(out, "    \"\"\"{desc_with_period}\"\"\"");
755
756    // Check if any assertion is an error assertion.
757    let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
758
759    // Build argument expressions from config.
760    let mut arg_bindings = Vec::new();
761    let mut kwarg_exprs = Vec::new();
762    for arg in &call_config.args {
763        let var_name = &arg.name;
764
765        if arg.arg_type == "handle" {
766            // Generate a create_engine (or equivalent) call and pass the variable.
767            // If there's config data, construct a CrawlConfig with kwargs.
768            let constructor_name = format!("create_{}", arg.name.to_snake_case());
769            let config_value = resolve_field(&fixture.input, &arg.field);
770            if config_value.is_null()
771                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
772            {
773                arg_bindings.push(format!("    {var_name} = {constructor_name}(None)"));
774            } else if let Some(obj) = config_value.as_object() {
775                // Build kwargs for the config constructor (CrawlConfig(key=val, ...)).
776                // For fields with a nested type mapping, wrap the dict value in the
777                // appropriate typed constructor instead of passing a plain dict.
778                let kwargs: Vec<String> = obj
779                    .iter()
780                    .map(|(k, v)| {
781                        let snake_key = k.to_snake_case();
782                        let py_val = if let Some(type_name) = handle_nested_types.get(k) {
783                            // Wrap the nested dict in the typed constructor.
784                            if let Some(nested_obj) = v.as_object() {
785                                if nested_obj.is_empty() {
786                                    // Empty dict: use the default constructor.
787                                    format!("{type_name}()")
788                                } else if handle_dict_types.contains(k) {
789                                    // The outer Python config type (e.g. CrawlConfig) accepts a
790                                    // plain dict for this field (e.g. `auth: dict | None`).
791                                    // The binding-layer wrapper (e.g. api.py) creates the typed
792                                    // object internally, so we must NOT pre-wrap it here.
793                                    json_to_python_literal(v)
794                                } else {
795                                    // Type takes keyword arguments.
796                                    let nested_kwargs: Vec<String> = nested_obj
797                                        .iter()
798                                        .map(|(nk, nv)| {
799                                            let nested_snake_key = nk.to_snake_case();
800                                            format!("{nested_snake_key}={}", json_to_python_literal(nv))
801                                        })
802                                        .collect();
803                                    format!("{type_name}({})", nested_kwargs.join(", "))
804                                }
805                            } else {
806                                // Non-object value: use as-is.
807                                json_to_python_literal(v)
808                            }
809                        } else if k == "request_timeout" {
810                            // The Python binding converts request_timeout with Duration::from_secs
811                            // (seconds) while fixtures specify values in milliseconds. Divide by
812                            // 1000 to compensate: e.g., 1 ms → 0 s (immediate timeout),
813                            // 5000 ms → 5 s. This keeps test semantics consistent with the
814                            // fixture intent.
815                            if let Some(ms) = v.as_u64() {
816                                format!("{}", ms / 1000)
817                            } else {
818                                json_to_python_literal(v)
819                            }
820                        } else {
821                            json_to_python_literal(v)
822                        };
823                        format!("{snake_key}={py_val}")
824                    })
825                    .collect();
826                // Use the options_type if configured, otherwise "CrawlConfig".
827                let config_class = options_type.unwrap_or("CrawlConfig");
828                let single_line = format!("    {var_name}_config = {config_class}({})", kwargs.join(", "));
829                if single_line.len() <= 120 {
830                    arg_bindings.push(single_line);
831                } else {
832                    // Split into multi-line for readability and E501 compliance.
833                    let mut lines = format!("    {var_name}_config = {config_class}(\n");
834                    for kw in &kwargs {
835                        lines.push_str(&format!("        {kw},\n"));
836                    }
837                    lines.push_str("    )");
838                    arg_bindings.push(lines);
839                }
840                arg_bindings.push(format!("    {var_name} = {constructor_name}({var_name}_config)"));
841            } else {
842                let literal = json_to_python_literal(config_value);
843                arg_bindings.push(format!("    {var_name} = {constructor_name}({literal})"));
844            }
845            kwarg_exprs.push(format!("{var_name}={var_name}"));
846            continue;
847        }
848
849        if arg.arg_type == "mock_url" {
850            let fixture_id = &fixture.id;
851            arg_bindings.push(format!(
852                "    {var_name} = os.environ['MOCK_SERVER_URL'] + '/fixtures/{fixture_id}'"
853            ));
854            kwarg_exprs.push(format!("{var_name}={var_name}"));
855            continue;
856        }
857
858        let value = resolve_field(&fixture.input, &arg.field);
859
860        if value.is_null() && arg.optional {
861            continue;
862        }
863
864        // For json_object args, use the configured options_via strategy.
865        if arg.arg_type == "json_object" && !value.is_null() {
866            match options_via {
867                "dict" => {
868                    // Pass as a plain Python dict literal.
869                    let literal = json_to_python_literal(value);
870                    let noqa = if literal.contains("/tmp/") {
871                        "  # noqa: S108"
872                    } else {
873                        ""
874                    };
875                    arg_bindings.push(format!("    {var_name} = {literal}{noqa}"));
876                    kwarg_exprs.push(format!("{var_name}={var_name}"));
877                    continue;
878                }
879                "json" => {
880                    // Pass via json.loads() with the raw JSON string.
881                    let json_str = serde_json::to_string(value).unwrap_or_default();
882                    let escaped = escape_python(&json_str);
883                    arg_bindings.push(format!("    {var_name} = json.loads(\"{escaped}\")"));
884                    kwarg_exprs.push(format!("{var_name}={var_name}"));
885                    continue;
886                }
887                _ => {
888                    // "kwargs" (default): construct OptionsType(key=val, ...).
889                    if let (Some(opts_type), Some(obj)) = (options_type, value.as_object()) {
890                        let kwargs: Vec<String> = obj
891                            .iter()
892                            .map(|(k, v)| {
893                                let snake_key = k.to_snake_case();
894                                let py_val = if let Some(enum_type) = enum_fields.get(k) {
895                                    // Map string value to enum constant.
896                                    if let Some(s) = v.as_str() {
897                                        let upper_val = s.to_shouty_snake_case();
898                                        format!("{enum_type}.{upper_val}")
899                                    } else {
900                                        json_to_python_literal(v)
901                                    }
902                                } else {
903                                    json_to_python_literal(v)
904                                };
905                                format!("{snake_key}={py_val}")
906                            })
907                            .collect();
908                        let constructor = format!("{opts_type}({})", kwargs.join(", "));
909                        arg_bindings.push(format!("    {var_name} = {constructor}"));
910                        kwarg_exprs.push(format!("{var_name}={var_name}"));
911                        continue;
912                    }
913                }
914            }
915        }
916
917        // For required args with no fixture value, use a language-appropriate default.
918        if value.is_null() && !arg.optional {
919            let default_val = match arg.arg_type.as_str() {
920                "string" => "\"\"".to_string(),
921                "int" | "integer" => "0".to_string(),
922                "float" | "number" => "0.0".to_string(),
923                "bool" | "boolean" => "False".to_string(),
924                _ => "None".to_string(),
925            };
926            arg_bindings.push(format!("    {var_name} = {default_val}"));
927            kwarg_exprs.push(format!("{var_name}={var_name}"));
928            continue;
929        }
930
931        let literal = json_to_python_literal(value);
932        let noqa = if literal.contains("/tmp/") {
933            "  # noqa: S108"
934        } else {
935            ""
936        };
937        arg_bindings.push(format!("    {var_name} = {literal}{noqa}"));
938        kwarg_exprs.push(format!("{var_name}={var_name}"));
939    }
940
941    // Generate visitor class if the fixture has a visitor spec.
942    if let Some(visitor_spec) = &fixture.visitor {
943        let _ = writeln!(out, "    class _TestVisitor:");
944        for (method_name, action) in &visitor_spec.callbacks {
945            emit_python_visitor_method(out, method_name, action);
946        }
947        kwarg_exprs.push("visitor=_TestVisitor()".to_string());
948    }
949
950    for binding in &arg_bindings {
951        let _ = writeln!(out, "{binding}");
952    }
953
954    let call_args = kwarg_exprs.join(", ");
955    let await_prefix = if is_async { "await " } else { "" };
956    let call_expr = format!("{await_prefix}{function_name}({call_args})");
957
958    if has_error_assertion {
959        // Find error assertion for optional message check.
960        let error_assertion = fixture.assertions.iter().find(|a| a.assertion_type == "error");
961        let has_message = error_assertion
962            .and_then(|a| a.value.as_ref())
963            .and_then(|v| v.as_str())
964            .is_some();
965
966        if has_message {
967            let _ = writeln!(out, "    with pytest.raises(Exception) as exc_info:  # noqa: B017");
968            let _ = writeln!(out, "        {call_expr}");
969            if let Some(msg) = error_assertion.and_then(|a| a.value.as_ref()).and_then(|v| v.as_str()) {
970                let escaped = escape_python(msg);
971                let _ = writeln!(out, "    assert \"{escaped}\" in str(exc_info.value)  # noqa: S101");
972            }
973        } else {
974            let _ = writeln!(out, "    with pytest.raises(Exception):  # noqa: B017");
975            let _ = writeln!(out, "        {call_expr}");
976        }
977
978        // Skip non-error assertions: `result` is not defined outside the
979        // `pytest.raises` block, so referencing it would trigger ruff F821.
980        return;
981    }
982
983    // Non-error path.
984    let has_usable_assertion = fixture.assertions.iter().any(|a| {
985        if a.assertion_type == "not_error" || a.assertion_type == "error" {
986            return false;
987        }
988        match &a.field {
989            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
990            _ => true,
991        }
992    });
993    let py_result_var = if has_usable_assertion {
994        result_var.to_string()
995    } else {
996        "_".to_string()
997    };
998    let _ = writeln!(out, "    {py_result_var} = {call_expr}");
999
1000    let fields_enum = &e2e_config.fields_enum;
1001    for assertion in &fixture.assertions {
1002        if assertion.assertion_type == "not_error" {
1003            // The call already raises on error in Python.
1004            continue;
1005        }
1006        render_assertion(out, assertion, result_var, field_resolver, fields_enum);
1007    }
1008}
1009
1010// ---------------------------------------------------------------------------
1011// Argument rendering
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}