Skip to main content

alef_e2e/codegen/
python.rs

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