Skip to main content

alef_e2e/codegen/
csharp.rs

1//! C# e2e test generator using xUnit.
2//!
3//! Generates `e2e/csharp/E2eTests.csproj` and `tests/{Category}Tests.cs`
4//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_csharp, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23/// C# e2e code generator.
24pub struct CSharpCodegen;
25
26impl E2eCodegen for CSharpCodegen {
27    fn generate(
28        &self,
29        groups: &[FixtureGroup],
30        e2e_config: &E2eConfig,
31        config: &ResolvedCrateConfig,
32    ) -> Result<Vec<GeneratedFile>> {
33        let lang = self.language_name();
34        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36        let mut files = Vec::new();
37
38        // Resolve call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let function_name = overrides
42            .and_then(|o| o.function.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.function.to_upper_camel_case());
45        let class_name = overrides
46            .and_then(|o| o.class.as_ref())
47            .cloned()
48            .unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
49        // The exception class is always {CrateName}Exception, generated by the C# backend.
50        let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
51        let namespace = overrides
52            .and_then(|o| o.module.as_ref())
53            .cloned()
54            .or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
55            .unwrap_or_else(|| {
56                if call.module.is_empty() {
57                    "Kreuzberg".to_string()
58                } else {
59                    call.module.to_upper_camel_case()
60                }
61            });
62        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
63        let result_var = &call.result_var;
64        let is_async = call.r#async;
65
66        // Resolve package config.
67        let cs_pkg = e2e_config.resolve_package("csharp");
68        let pkg_name = cs_pkg
69            .as_ref()
70            .and_then(|p| p.name.as_ref())
71            .cloned()
72            .unwrap_or_else(|| config.name.to_upper_camel_case());
73        // Alef scaffolds C# packages as packages/csharp/<Namespace>/<Namespace>.csproj.
74        let pkg_path = cs_pkg
75            .as_ref()
76            .and_then(|p| p.path.as_ref())
77            .cloned()
78            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
79        let pkg_version = cs_pkg
80            .as_ref()
81            .and_then(|p| p.version.as_ref())
82            .cloned()
83            .or_else(|| config.resolved_version())
84            .unwrap_or_else(|| "0.1.0".to_string());
85
86        // Generate the .csproj using a unique name derived from the package name so
87        // it does not conflict with any hand-written project files in the same directory.
88        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
89        files.push(GeneratedFile {
90            path: output_base.join(&csproj_name),
91            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
92            generated_header: false,
93        });
94
95        // Emit a TestSetup.cs whose ModuleInitializer chdirs to test_documents
96        // so fixture-relative paths like "docx/fake.docx" resolve correctly when
97        // dotnet test runs from bin/{Configuration}/{Tfm}.
98        files.push(GeneratedFile {
99            path: output_base.join("TestSetup.cs"),
100            content: render_test_setup(),
101            generated_header: true,
102        });
103
104        // Generate test files per category.
105        let tests_base = output_base.join("tests");
106        let field_resolver = FieldResolver::new(
107            &e2e_config.fields,
108            &e2e_config.fields_optional,
109            &e2e_config.result_fields,
110            &e2e_config.fields_array,
111            &std::collections::HashSet::new(),
112        );
113
114        // Resolve enum_fields and nested_types from C# override config.
115        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
116        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
117
118        // Build effective nested_types by merging defaults with configured overrides.
119        let mut effective_nested_types = default_csharp_nested_types();
120        if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
121            effective_nested_types.extend(overrides_map.clone());
122        }
123
124        for group in groups {
125            let active: Vec<&Fixture> = group
126                .fixtures
127                .iter()
128                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
129                .collect();
130
131            if active.is_empty() {
132                continue;
133            }
134
135            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
136            let filename = format!("{test_class}.cs");
137            let content = render_test_file(
138                &group.category,
139                &active,
140                &namespace,
141                &class_name,
142                &function_name,
143                &exception_class,
144                result_var,
145                &test_class,
146                &e2e_config.call.args,
147                &field_resolver,
148                result_is_simple,
149                is_async,
150                e2e_config,
151                enum_fields,
152                &effective_nested_types,
153            );
154            files.push(GeneratedFile {
155                path: tests_base.join(filename),
156                content,
157                generated_header: true,
158            });
159        }
160
161        Ok(files)
162    }
163
164    fn language_name(&self) -> &'static str {
165        "csharp"
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Rendering
171// ---------------------------------------------------------------------------
172
173fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
174    let pkg_ref = match dep_mode {
175        crate::config::DependencyMode::Registry => {
176            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
177        }
178        crate::config::DependencyMode::Local => {
179            format!("    <ProjectReference Include=\"{pkg_path}\" />")
180        }
181    };
182    format!(
183        r#"<Project Sdk="Microsoft.NET.Sdk">
184  <PropertyGroup>
185    <TargetFramework>net10.0</TargetFramework>
186    <Nullable>enable</Nullable>
187    <ImplicitUsings>enable</ImplicitUsings>
188    <IsPackable>false</IsPackable>
189    <IsTestProject>true</IsTestProject>
190  </PropertyGroup>
191
192  <ItemGroup>
193    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
194    <PackageReference Include="xunit" Version="{xunit}" />
195    <PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
196  </ItemGroup>
197
198  <ItemGroup>
199{pkg_ref}
200  </ItemGroup>
201</Project>
202"#,
203        ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
204        xunit = tv::nuget::XUNIT,
205        xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
206    )
207}
208
209fn render_test_setup() -> String {
210    let mut out = String::new();
211    out.push_str(&hash::header(CommentStyle::DoubleSlash));
212    out.push_str(
213        r#"using System;
214using System.IO;
215using System.Runtime.CompilerServices;
216
217namespace Kreuzberg.E2eTests;
218
219internal static class TestSetup
220{
221    [ModuleInitializer]
222    internal static void Init()
223    {
224        // Walk up from the assembly directory until we find the repo root
225        // (the directory containing test_documents/) so that fixture paths
226        // like "docx/fake.docx" resolve regardless of where dotnet test
227        // launched the runner from.
228        var dir = new DirectoryInfo(AppContext.BaseDirectory);
229        while (dir != null)
230        {
231            var candidate = Path.Combine(dir.FullName, "test_documents");
232            if (Directory.Exists(candidate))
233            {
234                Directory.SetCurrentDirectory(candidate);
235                return;
236            }
237            dir = dir.Parent;
238        }
239    }
240}
241"#,
242    );
243    out
244}
245
246#[allow(clippy::too_many_arguments)]
247fn render_test_file(
248    category: &str,
249    fixtures: &[&Fixture],
250    namespace: &str,
251    class_name: &str,
252    function_name: &str,
253    exception_class: &str,
254    result_var: &str,
255    test_class: &str,
256    args: &[crate::config::ArgMapping],
257    field_resolver: &FieldResolver,
258    result_is_simple: bool,
259    is_async: bool,
260    e2e_config: &E2eConfig,
261    enum_fields: &HashMap<String, String>,
262    nested_types: &HashMap<String, String>,
263) -> String {
264    let mut out = String::new();
265    out.push_str(&hash::header(CommentStyle::DoubleSlash));
266    // Always import System.Text.Json for the shared JsonOptions field.
267    let _ = writeln!(out, "using System;");
268    let _ = writeln!(out, "using System.Collections.Generic;");
269    let _ = writeln!(out, "using System.Linq;");
270    let _ = writeln!(out, "using System.Net.Http;");
271    let _ = writeln!(out, "using System.Text;");
272    let _ = writeln!(out, "using System.Text.Json;");
273    let _ = writeln!(out, "using System.Text.Json.Serialization;");
274    let _ = writeln!(out, "using System.Threading.Tasks;");
275    let _ = writeln!(out, "using Xunit;");
276    let _ = writeln!(out, "using {namespace};");
277    let _ = writeln!(out, "using static {namespace}.{class_name};");
278    let _ = writeln!(out);
279    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
280    let _ = writeln!(out);
281    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
282    let _ = writeln!(out, "public class {test_class}");
283    let _ = writeln!(out, "{{");
284    // Shared options used when deserializing config JSON in test setup.
285    // Mirrors the options used by the library to ensure enum values round-trip correctly.
286    let _ = writeln!(
287        out,
288        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
289    );
290    let _ = writeln!(out);
291
292    // Visitor class declarations accumulated across all fixtures — emitted as
293    // private nested classes inside the test class but outside any method body.
294    // C# does not allow local class declarations inside method bodies.
295    let mut visitor_class_decls: Vec<String> = Vec::new();
296
297    for (i, fixture) in fixtures.iter().enumerate() {
298        render_test_method(
299            &mut out,
300            &mut visitor_class_decls,
301            fixture,
302            class_name,
303            function_name,
304            exception_class,
305            result_var,
306            args,
307            field_resolver,
308            result_is_simple,
309            is_async,
310            e2e_config,
311            enum_fields,
312            nested_types,
313        );
314        if i + 1 < fixtures.len() {
315            let _ = writeln!(out);
316        }
317    }
318
319    // Emit visitor helper classes at class scope (after test methods).
320    for decl in &visitor_class_decls {
321        let _ = writeln!(out);
322        let _ = writeln!(out, "{decl}");
323    }
324
325    let _ = writeln!(out, "}}");
326    out
327}
328
329// ---------------------------------------------------------------------------
330// HTTP test rendering — shared-driver integration
331// ---------------------------------------------------------------------------
332
333/// Renderer that emits xUnit `[Fact] public async Task Test_*()` methods using
334/// `System.Net.Http.HttpClient` against the mock server at `MOCK_SERVER_URL`.
335/// Satisfies [`client::TestClientRenderer`] so the shared
336/// [`client::http_call::render_http_test`] driver drives the call sequence.
337struct CSharpTestClientRenderer;
338
339/// C# HttpMethod static properties are PascalCase (Get, Post, Put, Delete, …).
340fn to_csharp_http_method(method: &str) -> String {
341    let lower = method.to_ascii_lowercase();
342    let mut chars = lower.chars();
343    match chars.next() {
344        Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
345        None => String::new(),
346    }
347}
348
349/// Headers that belong to `request.Content.Headers` rather than `request.Headers`.
350///
351/// Adding these to `request.Headers` causes .NET to throw "Misused header name".
352const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
353    "content-length",
354    "host",
355    "connection",
356    "expect",
357    "transfer-encoding",
358    "upgrade",
359    // Content-Type is owned by request.Content.Headers and is set when
360    // StringContent is constructed; adding it to request.Headers throws.
361    "content-type",
362    // Other entity headers also belong to request.Content.Headers.
363    "content-encoding",
364    "content-language",
365    "content-location",
366    "content-md5",
367    "content-range",
368    "content-disposition",
369];
370
371/// Whether `name` (any case) belongs to `response.Content.Headers` rather than
372/// `response.Headers`. Picking the wrong collection causes .NET to throw
373/// "Misused header name".
374fn is_csharp_content_header(name: &str) -> bool {
375    matches!(
376        name.to_ascii_lowercase().as_str(),
377        "content-type"
378            | "content-length"
379            | "content-encoding"
380            | "content-language"
381            | "content-location"
382            | "content-md5"
383            | "content-range"
384            | "content-disposition"
385            | "expires"
386            | "last-modified"
387            | "allow"
388    )
389}
390
391impl client::TestClientRenderer for CSharpTestClientRenderer {
392    fn language_name(&self) -> &'static str {
393        "csharp"
394    }
395
396    /// Convert a fixture id to the PascalCase identifier used in `Test_{name}`.
397    fn sanitize_test_name(&self, id: &str) -> String {
398        id.to_upper_camel_case()
399    }
400
401    /// Emit `[Fact]` (or `[Fact(Skip = "…")]` for skipped tests), the method
402    /// signature, the opening brace, and the description comment.
403    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
404        if let Some(reason) = skip_reason {
405            let escaped_reason = escape_csharp(reason);
406            let _ = writeln!(out, "    [Fact(Skip = \"{escaped_reason}\")]");
407            let _ = writeln!(out, "    public async Task Test_{fn_name}()");
408        } else {
409            let _ = writeln!(out, "    [Fact]");
410            let _ = writeln!(out, "    public async Task Test_{fn_name}()");
411        }
412        let _ = writeln!(out, "    {{");
413        let _ = writeln!(out, "        // {description}");
414    }
415
416    /// Emit the closing `}` for a test method.
417    fn render_test_close(&self, out: &mut String) {
418        let _ = writeln!(out, "    }}");
419    }
420
421    /// Emit the `HttpRequestMessage` construction, headers, cookies, body, and
422    /// `var response = await client.SendAsync(request)`.
423    ///
424    /// The fixture path follows the mock-server convention `/fixtures/<id>`.
425    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
426        let method = to_csharp_http_method(ctx.method);
427        let path = escape_csharp(ctx.path);
428
429        let _ = writeln!(
430            out,
431            "        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
432        );
433        // Disable auto-follow so redirect-status fixtures (3xx) can assert the
434        // server's status code rather than the followed-target's status.
435        let _ = writeln!(
436            out,
437            "        using var handler = new System.Net.Http.HttpClientHandler {{ AllowAutoRedirect = false }};"
438        );
439        let _ = writeln!(
440            out,
441            "        using var client = new System.Net.Http.HttpClient(handler);"
442        );
443        let _ = writeln!(
444            out,
445            "        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");"
446        );
447
448        // Set body + Content-Type when a request body is present.
449        if let Some(body) = ctx.body {
450            let content_type = ctx.content_type.unwrap_or("application/json");
451            let json_str = serde_json::to_string(body).unwrap_or_default();
452            let escaped = escape_csharp(&json_str);
453            let _ = writeln!(
454                out,
455                "        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
456            );
457        }
458
459        // Add request headers (skip restricted headers that belong to Content.Headers).
460        for (name, value) in ctx.headers {
461            if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
462                continue;
463            }
464            let escaped_name = escape_csharp(name);
465            let escaped_value = escape_csharp(value);
466            let _ = writeln!(
467                out,
468                "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
469            );
470        }
471
472        // Combine cookies into a single `Cookie` header.
473        if !ctx.cookies.is_empty() {
474            let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
475            pairs.sort();
476            let cookie_header = escape_csharp(&pairs.join("; "));
477            let _ = writeln!(out, "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
478        }
479
480        let _ = writeln!(out, "        var response = await client.SendAsync(request);");
481    }
482
483    /// Emit `Assert.Equal(status, (int)response.StatusCode)`.
484    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
485        let _ = writeln!(out, "        Assert.Equal({status}, (int)response.StatusCode);");
486    }
487
488    /// Emit a response-header assertion.
489    ///
490    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
491    /// Picks `response.Content.Headers` vs `response.Headers` based on the header name.
492    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
493        let target = if is_csharp_content_header(name) {
494            "response.Content.Headers"
495        } else {
496            "response.Headers"
497        };
498        let escaped_name = escape_csharp(name);
499        match expected {
500            "<<present>>" => {
501                let _ = writeln!(
502                    out,
503                    "        Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");"
504                );
505            }
506            "<<absent>>" => {
507                let _ = writeln!(
508                    out,
509                    "        Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");"
510                );
511            }
512            "<<uuid>>" => {
513                // UUID regex: 8-4-4-4-12 hex groups.
514                let _ = writeln!(
515                    out,
516                    "        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var _uuidHdr) && System.Text.RegularExpressions.Regex.IsMatch(string.Join(\", \", _uuidHdr), @\"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$\"), \"header {escaped_name} is not a UUID\");"
517                );
518            }
519            literal => {
520                // Use a deterministic local-variable name derived from the header name so
521                // multiple header assertions in the same method body do not redeclare.
522                let var_name = format!("hdr{}", sanitize_ident(name));
523                let escaped_value = escape_csharp(literal);
524                let _ = writeln!(
525                    out,
526                    "        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
527                );
528            }
529        }
530    }
531
532    /// Emit a JSON body equality assertion via `JsonDocument`.
533    ///
534    /// Plain-string bodies are compared with `Assert.Equal` after trimming.
535    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
536        match expected {
537            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
538                let json_str = serde_json::to_string(expected).unwrap_or_default();
539                let escaped = escape_csharp(&json_str);
540                let _ = writeln!(
541                    out,
542                    "        var bodyText = await response.Content.ReadAsStringAsync();"
543                );
544                let _ = writeln!(out, "        var body = JsonDocument.Parse(bodyText).RootElement;");
545                let _ = writeln!(
546                    out,
547                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
548                );
549                let _ = writeln!(
550                    out,
551                    "        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
552                );
553            }
554            serde_json::Value::String(s) => {
555                let escaped = escape_csharp(s);
556                let _ = writeln!(
557                    out,
558                    "        var bodyText = await response.Content.ReadAsStringAsync();"
559                );
560                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
561            }
562            other => {
563                let escaped = escape_csharp(&other.to_string());
564                let _ = writeln!(
565                    out,
566                    "        var bodyText = await response.Content.ReadAsStringAsync();"
567                );
568                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
569            }
570        }
571    }
572
573    /// Emit per-field equality assertions for a partial body match.
574    ///
575    /// Uses a separate `partialBodyText` local so it does not collide with
576    /// `bodyText` if `render_assert_json_body` was also called.
577    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
578        if let Some(obj) = expected.as_object() {
579            let _ = writeln!(
580                out,
581                "        var partialBodyText = await response.Content.ReadAsStringAsync();"
582            );
583            let _ = writeln!(
584                out,
585                "        var partialBody = JsonDocument.Parse(partialBodyText).RootElement;"
586            );
587            for (key, val) in obj {
588                let escaped_key = escape_csharp(key);
589                let json_str = serde_json::to_string(val).unwrap_or_default();
590                let escaped_val = escape_csharp(&json_str);
591                let var_name = format!("expected{}", key.to_upper_camel_case());
592                let _ = writeln!(
593                    out,
594                    "        var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;"
595                );
596                let _ = writeln!(
597                    out,
598                    "        Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");"
599                );
600            }
601        }
602    }
603
604    /// Emit validation-error assertions by checking each expected `msg` string
605    /// appears in the JSON-encoded body.
606    fn render_assert_validation_errors(
607        &self,
608        out: &mut String,
609        _response_var: &str,
610        errors: &[ValidationErrorExpectation],
611    ) {
612        let _ = writeln!(
613            out,
614            "        var validationBodyText = await response.Content.ReadAsStringAsync();"
615        );
616        for err in errors {
617            let escaped_msg = escape_csharp(&err.msg);
618            let _ = writeln!(out, "        Assert.Contains(\"{escaped_msg}\", validationBodyText);");
619        }
620    }
621}
622
623/// Render an HTTP server test method using the shared [`client::http_call::render_http_test`]
624/// driver via [`CSharpTestClientRenderer`].
625fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
626    client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
627}
628
629#[allow(clippy::too_many_arguments)]
630fn render_test_method(
631    out: &mut String,
632    visitor_class_decls: &mut Vec<String>,
633    fixture: &Fixture,
634    class_name: &str,
635    _function_name: &str,
636    exception_class: &str,
637    _result_var: &str,
638    _args: &[crate::config::ArgMapping],
639    field_resolver: &FieldResolver,
640    result_is_simple: bool,
641    _is_async: bool,
642    e2e_config: &E2eConfig,
643    enum_fields: &HashMap<String, String>,
644    nested_types: &HashMap<String, String>,
645) {
646    let method_name = fixture.id.to_upper_camel_case();
647    let description = &fixture.description;
648
649    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
650    if let Some(http) = &fixture.http {
651        render_http_test_method(out, fixture, http);
652        return;
653    }
654
655    // Non-HTTP fixtures with no mock_response: skip only if the C# binding
656    // does not have a callable for this function via [e2e.call.overrides.csharp].
657    if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
658        let _ = writeln!(
659            out,
660            "    [Fact(Skip = \"non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function\")]"
661        );
662        let _ = writeln!(out, "    public void Test_{method_name}()");
663        let _ = writeln!(out, "    {{");
664        let _ = writeln!(out, "        // {description}");
665        let _ = writeln!(out, "    }}");
666        return;
667    }
668
669    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
670
671    // Resolve call config per-fixture so named calls (e.g. "parse") use the
672    // correct function name, result variable, and async flag.
673    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
674    let lang = "csharp";
675    let cs_overrides = call_config.overrides.get(lang);
676    let effective_function_name = cs_overrides
677        .and_then(|o| o.function.as_ref())
678        .cloned()
679        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
680    let effective_result_var = &call_config.result_var;
681    let effective_is_async = call_config.r#async;
682    let function_name = effective_function_name.as_str();
683    let result_var = effective_result_var.as_str();
684    let is_async = effective_is_async;
685    let args = call_config.args.as_slice();
686
687    // Per-call overrides: result shape, void returns, extra trailing args.
688    // Pull `result_is_simple` from the per-call config first (call-level value
689    // wins, then per-language override, then the top-level call's value).
690    let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
691    let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
692    let returns_void = call_config.returns_void;
693    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
694    // options_type: prefer per-call override, fall back to top-level csharp override.
695    let top_level_options_type = e2e_config
696        .call
697        .overrides
698        .get("csharp")
699        .and_then(|o| o.options_type.as_deref());
700    let effective_options_type = cs_overrides
701        .and_then(|o| o.options_type.as_deref())
702        .or(top_level_options_type);
703
704    let (mut setup_lines, args_str) = build_args_and_setup(
705        &fixture.input,
706        args,
707        class_name,
708        effective_options_type,
709        enum_fields,
710        nested_types,
711        &fixture.id,
712    );
713
714    // Build visitor if present: instantiate in method body, declare class at file scope.
715    let mut visitor_arg = String::new();
716    let has_visitor = fixture.visitor.is_some();
717    if let Some(visitor_spec) = &fixture.visitor {
718        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
719    }
720
721    // When a visitor is present, embed it in the options object instead of passing as a separate arg.
722    // args_str should contain the function arguments with null for missing options (e.g., "html, null").
723    // We need to replace that null with a ConversionOptions instance that has Visitor set.
724    let final_args = if has_visitor && !visitor_arg.is_empty() {
725        let opts_type = effective_options_type.unwrap_or("ConversionOptions");
726        if args_str.contains("JsonSerializer.Deserialize") {
727            // Deserialize form: extract the deserialized object and set Visitor on it
728            setup_lines.push(format!("var options = {args_str};"));
729            setup_lines.push(format!("options.Visitor = {visitor_arg};"));
730            "options".to_string()
731        } else if args_str.ends_with(", null") {
732            // Replace trailing ", null" with options
733            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
734            let trimmed = args_str[..args_str.len() - 6].to_string(); // Remove ", null" (6 chars including space)
735            format!("{trimmed}, options")
736        } else if args_str.contains(", null,") {
737            // Options parameter is null in the middle; replace it
738            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
739            args_str.replace(", null,", ", options,")
740        } else if args_str.is_empty() {
741            // No options were provided; create new instance with Visitor
742            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
743            "options".to_string()
744        } else {
745            // Fall back to appending options
746            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
747            format!("{args_str}, options")
748        }
749    } else if extra_args_slice.is_empty() {
750        args_str
751    } else if args_str.is_empty() {
752        extra_args_slice.join(", ")
753    } else {
754        format!("{args_str}, {}", extra_args_slice.join(", "))
755    };
756
757    // Always use the base function name (Convert) regardless of visitor presence
758    // The visitor is now handled internally via options.Visitor
759    let effective_function_name = function_name.to_string();
760
761    let return_type = if is_async { "async Task" } else { "void" };
762    let await_kw = if is_async { "await " } else { "" };
763
764    // Client factory: when set, create a client instance and call methods on it
765    // rather than using static class calls.
766    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
767        e2e_config
768            .call
769            .overrides
770            .get("csharp")
771            .and_then(|o| o.client_factory.as_deref())
772    });
773    let call_target = if client_factory.is_some() {
774        "client".to_string()
775    } else {
776        class_name.to_string()
777    };
778
779    let _ = writeln!(out, "    [Fact]");
780    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
781    let _ = writeln!(out, "    {{");
782    let _ = writeln!(out, "        // {description}");
783
784    for line in &setup_lines {
785        let _ = writeln!(out, "        {line}");
786    }
787
788    // Emit client creation when client_factory is configured.
789    if let Some(factory) = client_factory {
790        let factory_name = factory.to_upper_camel_case();
791        let fixture_id = &fixture.id;
792        let _ = writeln!(
793            out,
794            "        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";"
795        );
796        let _ = writeln!(
797            out,
798            "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);"
799        );
800    }
801
802    if expects_error {
803        if is_async {
804            let _ = writeln!(
805                out,
806                "        await Assert.ThrowsAnyAsync<{exception_class}>(() => {call_target}.{effective_function_name}({final_args}));"
807            );
808        } else {
809            let _ = writeln!(
810                out,
811                "        Assert.ThrowsAny<{exception_class}>(() => {call_target}.{effective_function_name}({final_args}));"
812            );
813        }
814        let _ = writeln!(out, "    }}");
815        return;
816    }
817
818    let result_is_vec = call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec);
819    let result_is_array = call_config.result_is_array;
820
821    if returns_void {
822        let _ = writeln!(
823            out,
824            "        {await_kw}{call_target}.{effective_function_name}({final_args});"
825        );
826    } else {
827        let _ = writeln!(
828            out,
829            "        var {result_var} = {await_kw}{call_target}.{effective_function_name}({final_args});"
830        );
831        for assertion in &fixture.assertions {
832            render_assertion(
833                out,
834                assertion,
835                result_var,
836                class_name,
837                exception_class,
838                field_resolver,
839                effective_result_is_simple,
840                result_is_vec,
841                result_is_array,
842            );
843        }
844    }
845
846    let _ = writeln!(out, "    }}");
847}
848
849/// Build setup lines (e.g. handle creation) and the argument list for the function call.
850///
851/// Returns `(setup_lines, args_string)`.
852fn build_args_and_setup(
853    input: &serde_json::Value,
854    args: &[crate::config::ArgMapping],
855    class_name: &str,
856    options_type: Option<&str>,
857    enum_fields: &HashMap<String, String>,
858    nested_types: &HashMap<String, String>,
859    fixture_id: &str,
860) -> (Vec<String>, String) {
861    if args.is_empty() {
862        return (Vec::new(), String::new());
863    }
864
865    let mut setup_lines: Vec<String> = Vec::new();
866    let mut parts: Vec<String> = Vec::new();
867
868    for arg in args {
869        if arg.arg_type == "bytes" {
870            // bytes args must be passed as byte[] in C#.
871            // Treat the fixture value as a UTF-8 string and convert to bytes.
872            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
873            let val = input.get(field);
874            match val {
875                None | Some(serde_json::Value::Null) if arg.optional => {
876                    parts.push("null".to_string());
877                }
878                None | Some(serde_json::Value::Null) => {
879                    parts.push("System.Array.Empty<byte>()".to_string());
880                }
881                Some(v) => {
882                    let cs_str = json_to_csharp(v);
883                    parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
884                }
885            }
886            continue;
887        }
888
889        if arg.arg_type == "mock_url" {
890            setup_lines.push(format!(
891                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
892                arg.name,
893            ));
894            parts.push(arg.name.clone());
895            continue;
896        }
897
898        if arg.arg_type == "handle" {
899            // Generate a CreateEngine (or equivalent) call and pass the variable.
900            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
901            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
902            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
903            if config_value.is_null()
904                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
905            {
906                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
907            } else {
908                // Sort discriminator fields ("type") to appear first in nested objects so
909                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
910                // reading other properties (a requirement as of .NET 8).
911                let sorted = sort_discriminator_first(config_value.clone());
912                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
913                let name = &arg.name;
914                setup_lines.push(format!(
915                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
916                    escape_csharp(&json_str),
917                ));
918                setup_lines.push(format!(
919                    "var {} = {class_name}.{constructor_name}({name}Config);",
920                    arg.name,
921                    name = name,
922                ));
923            }
924            parts.push(arg.name.clone());
925            continue;
926        }
927
928        // When field is exactly "input", treat the entire input object as the value.
929        // This matches the convention used by other language generators (e.g. Go).
930        let val: Option<&serde_json::Value> = if arg.field == "input" {
931            Some(input)
932        } else {
933            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
934            input.get(field)
935        };
936        match val {
937            None | Some(serde_json::Value::Null) if arg.optional => {
938                // Optional arg with no fixture value: pass null explicitly since
939                // C# nullable parameters still require an argument at the call site.
940                parts.push("null".to_string());
941                continue;
942            }
943            None | Some(serde_json::Value::Null) => {
944                // Required arg with no fixture value: pass a language-appropriate default.
945                // For json_object args with a known options_type, use `new OptionsType()`
946                // so the generated code compiles when the method parameter is non-nullable.
947                let default_val = match arg.arg_type.as_str() {
948                    "string" => "\"\"".to_string(),
949                    "int" | "integer" => "0".to_string(),
950                    "float" | "number" => "0.0d".to_string(),
951                    "bool" | "boolean" => "false".to_string(),
952                    "json_object" => {
953                        if let Some(opts_type) = options_type {
954                            format!("new {opts_type}()")
955                        } else {
956                            "null".to_string()
957                        }
958                    }
959                    _ => "null".to_string(),
960                };
961                parts.push(default_val);
962            }
963            Some(v) => {
964                if arg.arg_type == "json_object" {
965                    // Array value: generate a typed List<T> based on element_type.
966                    if let Some(arr) = v.as_array() {
967                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
968                        continue;
969                    }
970                    // Object value with known type: generate idiomatic C# object initializer.
971                    if let Some(opts_type) = options_type {
972                        if let Some(obj) = v.as_object() {
973                            parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
974                            continue;
975                        }
976                    }
977                }
978                parts.push(json_to_csharp(v));
979            }
980        }
981    }
982
983    (setup_lines, parts.join(", "))
984}
985
986/// Convert a JSON array to a typed C# `List<T>` expression.
987///
988/// Mapping from `ArgMapping::element_type`:
989/// - `None` or any string type → `List<string>`
990/// - `"f32"` → `List<float>` with `(float)` casts
991/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
992/// - `"BatchBytesItem"` / `"BatchFileItem"` → array of batch item instances
993fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
994    match element_type {
995        Some("BatchBytesItem") => {
996            let items: Vec<String> = arr
997                .iter()
998                .filter_map(|v| v.as_object())
999                .map(|obj| {
1000                    let content = obj.get("content").and_then(|v| v.as_array());
1001                    let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1002                    let content_code = if let Some(arr) = content {
1003                        let bytes: Vec<String> = arr
1004                            .iter()
1005                            .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1006                            .collect();
1007                        format!("new byte[] {{ {} }}", bytes.join(", "))
1008                    } else {
1009                        "new byte[] { }".to_string()
1010                    };
1011                    format!(
1012                        "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1013                        content_code, mime_type
1014                    )
1015                })
1016                .collect();
1017            format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1018        }
1019        Some("BatchFileItem") => {
1020            let items: Vec<String> = arr
1021                .iter()
1022                .filter_map(|v| v.as_object())
1023                .map(|obj| {
1024                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1025                    format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1026                })
1027                .collect();
1028            format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1029        }
1030        Some("f32") => {
1031            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1032            format!("new List<float>() {{ {} }}", items.join(", "))
1033        }
1034        Some("(String, String)") => {
1035            let items: Vec<String> = arr
1036                .iter()
1037                .map(|v| {
1038                    let strs: Vec<String> = v
1039                        .as_array()
1040                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1041                    format!("new List<string>() {{ {} }}", strs.join(", "))
1042                })
1043                .collect();
1044            format!("new List<List<string>>() {{ {} }}", items.join(", "))
1045        }
1046        Some(et)
1047            if et != "f32"
1048                && et != "(String, String)"
1049                && et != "string"
1050                && et != "BatchBytesItem"
1051                && et != "BatchFileItem" =>
1052        {
1053            // Class/record types: deserialize each element from JSON
1054            let items: Vec<String> = arr
1055                .iter()
1056                .map(|v| {
1057                    let json_str = serde_json::to_string(v).unwrap_or_default();
1058                    let escaped = escape_csharp(&json_str);
1059                    format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1060                })
1061                .collect();
1062            format!("new List<{et}>() {{ {} }}", items.join(", "))
1063        }
1064        _ => {
1065            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1066            format!("new List<string>() {{ {} }}", items.join(", "))
1067        }
1068    }
1069}
1070
1071#[allow(clippy::too_many_arguments)]
1072fn render_assertion(
1073    out: &mut String,
1074    assertion: &Assertion,
1075    result_var: &str,
1076    class_name: &str,
1077    exception_class: &str,
1078    field_resolver: &FieldResolver,
1079    result_is_simple: bool,
1080    result_is_vec: bool,
1081    result_is_array: bool,
1082) {
1083    // Handle synthetic / derived fields before the is_valid_for_result check
1084    // so they are never treated as struct property accesses on the result.
1085    if let Some(f) = &assertion.field {
1086        match f.as_str() {
1087            "chunks_have_content" => {
1088                let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
1089                match assertion.assertion_type.as_str() {
1090                    "is_true" => {
1091                        let _ = writeln!(out, "        Assert.True({pred});");
1092                    }
1093                    "is_false" => {
1094                        let _ = writeln!(out, "        Assert.False({pred});");
1095                    }
1096                    _ => {
1097                        let _ = writeln!(
1098                            out,
1099                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
1100                        );
1101                    }
1102                }
1103                return;
1104            }
1105            "chunks_have_embeddings" => {
1106                let pred =
1107                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
1108                match assertion.assertion_type.as_str() {
1109                    "is_true" => {
1110                        let _ = writeln!(out, "        Assert.True({pred});");
1111                    }
1112                    "is_false" => {
1113                        let _ = writeln!(out, "        Assert.False({pred});");
1114                    }
1115                    _ => {
1116                        let _ = writeln!(
1117                            out,
1118                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
1119                        );
1120                    }
1121                }
1122                return;
1123            }
1124            // ---- EmbedResponse virtual fields ----
1125            // embed_texts returns List<List<float>> in C# — no wrapper object.
1126            // result_var is the embedding matrix; use it directly.
1127            "embeddings" => {
1128                match assertion.assertion_type.as_str() {
1129                    "count_equals" => {
1130                        if let Some(val) = &assertion.value {
1131                            let cs_val = json_to_csharp(val);
1132                            let _ = writeln!(out, "        Assert.True({result_var}.Count == {cs_val});");
1133                        }
1134                    }
1135                    "count_min" => {
1136                        if let Some(val) = &assertion.value {
1137                            let cs_val = json_to_csharp(val);
1138                            let _ = writeln!(out, "        Assert.True({result_var}.Count >= {cs_val});");
1139                        }
1140                    }
1141                    "not_empty" => {
1142                        let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
1143                    }
1144                    "is_empty" => {
1145                        let _ = writeln!(out, "        Assert.Empty({result_var});");
1146                    }
1147                    _ => {
1148                        let _ = writeln!(
1149                            out,
1150                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'"
1151                        );
1152                    }
1153                }
1154                return;
1155            }
1156            "embedding_dimensions" => {
1157                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
1158                match assertion.assertion_type.as_str() {
1159                    "equals" => {
1160                        if let Some(val) = &assertion.value {
1161                            let cs_val = json_to_csharp(val);
1162                            let _ = writeln!(out, "        Assert.True({expr} == {cs_val});");
1163                        }
1164                    }
1165                    "greater_than" => {
1166                        if let Some(val) = &assertion.value {
1167                            let cs_val = json_to_csharp(val);
1168                            let _ = writeln!(out, "        Assert.True({expr} > {cs_val});");
1169                        }
1170                    }
1171                    _ => {
1172                        let _ = writeln!(
1173                            out,
1174                            "        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1175                        );
1176                    }
1177                }
1178                return;
1179            }
1180            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1181                let pred = match f.as_str() {
1182                    "embeddings_valid" => {
1183                        format!("{result_var}.All(e => e.Count > 0)")
1184                    }
1185                    "embeddings_finite" => {
1186                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
1187                    }
1188                    "embeddings_non_zero" => {
1189                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
1190                    }
1191                    "embeddings_normalized" => {
1192                        format!(
1193                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
1194                        )
1195                    }
1196                    _ => unreachable!(),
1197                };
1198                match assertion.assertion_type.as_str() {
1199                    "is_true" => {
1200                        let _ = writeln!(out, "        Assert.True({pred});");
1201                    }
1202                    "is_false" => {
1203                        let _ = writeln!(out, "        Assert.False({pred});");
1204                    }
1205                    _ => {
1206                        let _ = writeln!(
1207                            out,
1208                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
1209                        );
1210                    }
1211                }
1212                return;
1213            }
1214            // ---- keywords / keywords_count ----
1215            // C# ExtractionResult does not expose extracted_keywords; skip.
1216            "keywords" | "keywords_count" => {
1217                let _ = writeln!(
1218                    out,
1219                    "        // skipped: field '{f}' not available on C# ExtractionResult"
1220                );
1221                return;
1222            }
1223            _ => {}
1224        }
1225    }
1226
1227    // Skip assertions on fields that don't exist on the result type.
1228    if let Some(f) = &assertion.field {
1229        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1230            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1231            return;
1232        }
1233    }
1234
1235    // For count assertions on list results with no field specified, use the list directly.
1236    // Otherwise, when the result is a List<T>, index into the first element for field access.
1237    let is_count_assertion = matches!(
1238        assertion.assertion_type.as_str(),
1239        "count_equals" | "count_min" | "count_max"
1240    );
1241    let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
1242    let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
1243
1244    let effective_result_var: String = if result_is_vec && !use_list_directly {
1245        format!("{result_var}[0]")
1246    } else {
1247        result_var.to_string()
1248    };
1249
1250    let field_expr = if result_is_simple {
1251        effective_result_var.clone()
1252    } else {
1253        match &assertion.field {
1254            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
1255            _ => effective_result_var.clone(),
1256        }
1257    };
1258
1259    // Determine if field_expr is a list or complex object that requires JSON serialization
1260    // for string-based assertions (contains, not_contains, etc.). List<T>.ToString() in C#
1261    // returns the type name, not the contents.
1262    let field_needs_json_serialize = if result_is_simple {
1263        // Simple results are scalars, but when they're also arrays (e.g., List<string>),
1264        // JSON-serialize so substring checks see actual content, not the type name.
1265        result_is_array
1266    } else {
1267        match &assertion.field {
1268            Some(f) if !f.is_empty() => field_resolver.is_array(f),
1269            // No field specified — the whole result object; needs serialization when complex.
1270            _ => !result_is_simple,
1271        }
1272    };
1273    // Build the string representation of field_expr for substring-based assertions.
1274    let field_as_str = if field_needs_json_serialize {
1275        format!("JsonSerializer.Serialize({field_expr})")
1276    } else {
1277        format!("{field_expr}.ToString()")
1278    };
1279
1280    match assertion.assertion_type.as_str() {
1281        "equals" => {
1282            if let Some(expected) = &assertion.value {
1283                let cs_val = json_to_csharp(expected);
1284                if expected.is_string() {
1285                    // Only call .Trim() on string fields.
1286                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}!.Trim());");
1287                } else if expected.as_bool() == Some(true) {
1288                    // Boolean true: use Assert.True to avoid xUnit2004 warning.
1289                    let _ = writeln!(out, "        Assert.True({field_expr});");
1290                } else if expected.as_bool() == Some(false) {
1291                    // Boolean false: use Assert.False to avoid xUnit2004 warning.
1292                    let _ = writeln!(out, "        Assert.False({field_expr});");
1293                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1294                    // Integer values: use Assert.True(x == n) to avoid xUnit overload
1295                    // resolution ambiguity (int vs uint vs long vs DateTime).
1296                    let _ = writeln!(out, "        Assert.True({field_expr} == {cs_val});");
1297                } else {
1298                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
1299                }
1300            }
1301        }
1302        "contains" => {
1303            if let Some(expected) = &assertion.value {
1304                // Lowercase both expected and actual so that enum fields (where .ToString()
1305                // returns the PascalCase C# member name like "Anchor") correctly match
1306                // fixture snake_case values like "anchor".  String fields are unaffected
1307                // because lowercasing both sides preserves substring matches.
1308                // List/complex fields use JsonSerializer.Serialize() since List<T>.ToString()
1309                // returns the type name, not the contents.
1310                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1311                let cs_val = lower_expected
1312                    .as_deref()
1313                    .map(|s| format!("\"{}\"", escape_csharp(s)))
1314                    .unwrap_or_else(|| json_to_csharp(expected));
1315                let _ = writeln!(out, "        Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1316            }
1317        }
1318        "contains_all" => {
1319            if let Some(values) = &assertion.values {
1320                for val in values {
1321                    let lower_val = val.as_str().map(|s| s.to_lowercase());
1322                    let cs_val = lower_val
1323                        .as_deref()
1324                        .map(|s| format!("\"{}\"", escape_csharp(s)))
1325                        .unwrap_or_else(|| json_to_csharp(val));
1326                    let _ = writeln!(out, "        Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1327                }
1328            }
1329        }
1330        "not_contains" => {
1331            if let Some(expected) = &assertion.value {
1332                let cs_val = json_to_csharp(expected);
1333                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_as_str});");
1334            }
1335        }
1336        "not_empty" => {
1337            if field_needs_json_serialize {
1338                let _ = writeln!(out, "        Assert.NotEmpty({field_expr});");
1339            } else {
1340                let _ = writeln!(
1341                    out,
1342                    "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
1343                );
1344            }
1345        }
1346        "is_empty" => {
1347            if field_needs_json_serialize {
1348                let _ = writeln!(out, "        Assert.Empty({field_expr});");
1349            } else {
1350                let _ = writeln!(
1351                    out,
1352                    "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
1353                );
1354            }
1355        }
1356        "contains_any" => {
1357            if let Some(values) = &assertion.values {
1358                let checks: Vec<String> = values
1359                    .iter()
1360                    .map(|v| {
1361                        let cs_val = json_to_csharp(v);
1362                        format!("{field_as_str}.Contains({cs_val})")
1363                    })
1364                    .collect();
1365                let joined = checks.join(" || ");
1366                let _ = writeln!(
1367                    out,
1368                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
1369                );
1370            }
1371        }
1372        "greater_than" => {
1373            if let Some(val) = &assertion.value {
1374                let cs_val = json_to_csharp(val);
1375                let _ = writeln!(
1376                    out,
1377                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
1378                );
1379            }
1380        }
1381        "less_than" => {
1382            if let Some(val) = &assertion.value {
1383                let cs_val = json_to_csharp(val);
1384                let _ = writeln!(
1385                    out,
1386                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
1387                );
1388            }
1389        }
1390        "greater_than_or_equal" => {
1391            if let Some(val) = &assertion.value {
1392                let cs_val = json_to_csharp(val);
1393                let _ = writeln!(
1394                    out,
1395                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1396                );
1397            }
1398        }
1399        "less_than_or_equal" => {
1400            if let Some(val) = &assertion.value {
1401                let cs_val = json_to_csharp(val);
1402                let _ = writeln!(
1403                    out,
1404                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
1405                );
1406            }
1407        }
1408        "starts_with" => {
1409            if let Some(expected) = &assertion.value {
1410                let cs_val = json_to_csharp(expected);
1411                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
1412            }
1413        }
1414        "ends_with" => {
1415            if let Some(expected) = &assertion.value {
1416                let cs_val = json_to_csharp(expected);
1417                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
1418            }
1419        }
1420        "min_length" => {
1421            if let Some(val) = &assertion.value {
1422                if let Some(n) = val.as_u64() {
1423                    let _ = writeln!(
1424                        out,
1425                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
1426                    );
1427                }
1428            }
1429        }
1430        "max_length" => {
1431            if let Some(val) = &assertion.value {
1432                if let Some(n) = val.as_u64() {
1433                    let _ = writeln!(
1434                        out,
1435                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
1436                    );
1437                }
1438            }
1439        }
1440        "count_min" => {
1441            if let Some(val) = &assertion.value {
1442                if let Some(n) = val.as_u64() {
1443                    let _ = writeln!(
1444                        out,
1445                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
1446                    );
1447                }
1448            }
1449        }
1450        "count_equals" => {
1451            if let Some(val) = &assertion.value {
1452                if let Some(n) = val.as_u64() {
1453                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
1454                }
1455            }
1456        }
1457        "is_true" => {
1458            let _ = writeln!(out, "        Assert.True({field_expr});");
1459        }
1460        "is_false" => {
1461            let _ = writeln!(out, "        Assert.False({field_expr});");
1462        }
1463        "not_error" => {
1464            // Already handled by the call succeeding without exception.
1465        }
1466        "error" => {
1467            // Handled at the test method level.
1468        }
1469        "method_result" => {
1470            if let Some(method_name) = &assertion.method {
1471                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1472                let check = assertion.check.as_deref().unwrap_or("is_true");
1473                match check {
1474                    "equals" => {
1475                        if let Some(val) = &assertion.value {
1476                            if val.as_bool() == Some(true) {
1477                                let _ = writeln!(out, "        Assert.True({call_expr});");
1478                            } else if val.as_bool() == Some(false) {
1479                                let _ = writeln!(out, "        Assert.False({call_expr});");
1480                            } else {
1481                                let cs_val = json_to_csharp(val);
1482                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
1483                            }
1484                        }
1485                    }
1486                    "is_true" => {
1487                        let _ = writeln!(out, "        Assert.True({call_expr});");
1488                    }
1489                    "is_false" => {
1490                        let _ = writeln!(out, "        Assert.False({call_expr});");
1491                    }
1492                    "greater_than_or_equal" => {
1493                        if let Some(val) = &assertion.value {
1494                            let n = val.as_u64().unwrap_or(0);
1495                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
1496                        }
1497                    }
1498                    "count_min" => {
1499                        if let Some(val) = &assertion.value {
1500                            let n = val.as_u64().unwrap_or(0);
1501                            let _ = writeln!(
1502                                out,
1503                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
1504                            );
1505                        }
1506                    }
1507                    "is_error" => {
1508                        let _ = writeln!(
1509                            out,
1510                            "        Assert.ThrowsAny<{exception_class}>(() => {{ {call_expr}; }});"
1511                        );
1512                    }
1513                    "contains" => {
1514                        if let Some(val) = &assertion.value {
1515                            let cs_val = json_to_csharp(val);
1516                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
1517                        }
1518                    }
1519                    other_check => {
1520                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1521                    }
1522                }
1523            } else {
1524                panic!("C# e2e generator: method_result assertion missing 'method' field");
1525            }
1526        }
1527        "matches_regex" => {
1528            if let Some(expected) = &assertion.value {
1529                let cs_val = json_to_csharp(expected);
1530                let _ = writeln!(out, "        Assert.Matches({cs_val}, {field_expr});");
1531            }
1532        }
1533        other => {
1534            panic!("C# e2e generator: unsupported assertion type: {other}");
1535        }
1536    }
1537}
1538
1539/// Recursively sort JSON objects so that any key named `"type"` appears first.
1540///
1541/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
1542/// the first property when deserializing polymorphic types. Fixture config values
1543/// serialised via serde_json preserve insertion/alphabetical order, which may put
1544/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
1545fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1546    match value {
1547        serde_json::Value::Object(map) => {
1548            let mut sorted = serde_json::Map::with_capacity(map.len());
1549            // Insert "type" first if present.
1550            if let Some(type_val) = map.get("type") {
1551                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1552            }
1553            for (k, v) in map {
1554                if k != "type" {
1555                    sorted.insert(k, sort_discriminator_first(v));
1556                }
1557            }
1558            serde_json::Value::Object(sorted)
1559        }
1560        serde_json::Value::Array(arr) => {
1561            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1562        }
1563        other => other,
1564    }
1565}
1566
1567/// Convert a `serde_json::Value` to a C# literal string.
1568fn json_to_csharp(value: &serde_json::Value) -> String {
1569    match value {
1570        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1571        serde_json::Value::Bool(true) => "true".to_string(),
1572        serde_json::Value::Bool(false) => "false".to_string(),
1573        serde_json::Value::Number(n) => {
1574            if n.is_f64() {
1575                format!("{}d", n)
1576            } else {
1577                n.to_string()
1578            }
1579        }
1580        serde_json::Value::Null => "null".to_string(),
1581        serde_json::Value::Array(arr) => {
1582            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1583            format!("new[] {{ {} }}", items.join(", "))
1584        }
1585        serde_json::Value::Object(_) => {
1586            let json_str = serde_json::to_string(value).unwrap_or_default();
1587            format!("\"{}\"", escape_csharp(&json_str))
1588        }
1589    }
1590}
1591
1592/// Build default nested type mappings for C# extraction config types.
1593///
1594/// Maps known Kreuzberg/Kreuzcrawl config field names (in snake_case) to their
1595/// C# record type names (in PascalCase). These defaults allow e2e codegen to
1596/// automatically deserialize nested config objects without requiring explicit
1597/// configuration in alef.toml. User-provided overrides take precedence.
1598fn default_csharp_nested_types() -> HashMap<String, String> {
1599    [
1600        ("chunking", "ChunkingConfig"),
1601        ("ocr", "OcrConfig"),
1602        ("images", "ImageExtractionConfig"),
1603        ("html_output", "HtmlOutputConfig"),
1604        ("language_detection", "LanguageDetectionConfig"),
1605        ("postprocessor", "PostProcessorConfig"),
1606        ("acceleration", "AccelerationConfig"),
1607        ("email", "EmailConfig"),
1608        ("pages", "PageConfig"),
1609        ("pdf_options", "PdfConfig"),
1610        ("layout", "LayoutDetectionConfig"),
1611        ("tree_sitter", "TreeSitterConfig"),
1612        ("structured_extraction", "StructuredExtractionConfig"),
1613        ("content_filter", "ContentFilterConfig"),
1614        ("token_reduction", "TokenReductionOptions"),
1615        ("security_limits", "SecurityLimits"),
1616    ]
1617    .iter()
1618    .map(|(k, v)| (k.to_string(), v.to_string()))
1619    .collect()
1620}
1621
1622/// Emit a C# object initializer for a JSON options object.
1623///
1624/// - camelCase fixture keys → PascalCase C# property names
1625/// - Enum fields (from `enum_fields`) → `EnumType.Member`
1626/// - Nested objects with known type (from `nested_types`) → `JsonSerializer.Deserialize<T>(...)`
1627/// - Arrays → `new List<string> { ... }`
1628/// - Primitives → C# literals via `json_to_csharp`
1629fn csharp_object_initializer(
1630    obj: &serde_json::Map<String, serde_json::Value>,
1631    type_name: &str,
1632    enum_fields: &HashMap<String, String>,
1633    nested_types: &HashMap<String, String>,
1634) -> String {
1635    if obj.is_empty() {
1636        return format!("new {type_name}()");
1637    }
1638
1639    // Fields that are JsonElement? in the C# binding (discriminated unions in Rust).
1640    // These must be wrapped in JsonDocument.Parse() to create a JsonElement from a value.
1641    static JSON_ELEMENT_FIELDS: &[&str] = &["output_format"];
1642
1643    let props: Vec<String> = obj
1644        .iter()
1645        .map(|(key, val)| {
1646            let pascal_key = key.to_upper_camel_case();
1647            let cs_val = if let Some(enum_type) = enum_fields.get(key.as_str()) {
1648                // Enum: EnumType.Member
1649                let member = val
1650                    .as_str()
1651                    .map(|s| s.to_upper_camel_case())
1652                    .unwrap_or_else(|| "null".to_string());
1653                format!("{enum_type}.{member}")
1654            } else if let Some(nested_type) = nested_types.get(key.as_str()) {
1655                // Nested object: JSON deserialization (keys are typically single-word, matching JsonPropertyName)
1656                let normalized = normalize_csharp_enum_values(val, enum_fields);
1657                let json_str = serde_json::to_string(&normalized).unwrap_or_default();
1658                format!(
1659                    "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
1660                    escape_csharp(&json_str)
1661                )
1662            } else if let Some(arr) = val.as_array() {
1663                // Array: List<string>
1664                let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1665                format!("new List<string> {{ {} }}", items.join(", "))
1666            } else if JSON_ELEMENT_FIELDS.contains(&key.as_str()) {
1667                // JsonElement? fields: wrap the JSON value in JsonDocument.Parse().RootElement
1668                if val.is_null() {
1669                    "null".to_string()
1670                } else {
1671                    let json_str = serde_json::to_string(val).unwrap_or_default();
1672                    format!("JsonDocument.Parse(\"{}\").RootElement", escape_csharp(&json_str))
1673                }
1674            } else {
1675                json_to_csharp(val)
1676            };
1677            format!("{pascal_key} = {cs_val}")
1678        })
1679        .collect();
1680    format!("new {} {{ {} }}", type_name, props.join(", "))
1681}
1682
1683/// Convert enum values in a JSON object to lowercase to match C# [JsonPropertyName] attributes.
1684/// The JSON deserialization uses JsonPropertyName("lowercase_value"), so fixture enum values
1685/// (typically PascalCase like "Tildes") must be converted to lowercase ("tildes") for correct
1686/// deserialization with JsonStringEnumConverter.
1687fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
1688    match value {
1689        serde_json::Value::Object(map) => {
1690            let mut result = map.clone();
1691            for (key, val) in result.iter_mut() {
1692                if enum_fields.contains_key(key) {
1693                    // This is an enum field; convert the string value to lowercase.
1694                    if let Some(s) = val.as_str() {
1695                        *val = serde_json::Value::String(s.to_lowercase());
1696                    }
1697                }
1698            }
1699            serde_json::Value::Object(result)
1700        }
1701        other => other.clone(),
1702    }
1703}
1704
1705// ---------------------------------------------------------------------------
1706// Visitor generation
1707// ---------------------------------------------------------------------------
1708
1709/// Build a C# visitor: add an instantiation line to `setup_lines` and push
1710/// a private nested class declaration to `class_decls` (emitted at class scope,
1711/// outside any method body — C# does not allow local class declarations inside
1712/// methods).  Each fixture gets a unique class name derived from its ID to avoid
1713/// duplicate-name compile errors when multiple visitor fixtures exist per file.
1714/// Returns the visitor variable name for use as a call argument.
1715fn build_csharp_visitor(
1716    setup_lines: &mut Vec<String>,
1717    class_decls: &mut Vec<String>,
1718    fixture_id: &str,
1719    visitor_spec: &crate::fixture::VisitorSpec,
1720) -> String {
1721    use heck::ToUpperCamelCase;
1722    let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
1723    let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
1724
1725    setup_lines.push(format!("var {var_name} = new {class_name}();"));
1726
1727    // Build the class declaration string (indented for nesting inside the test class).
1728    let mut decl = String::new();
1729    let _ = writeln!(decl, "    private sealed class {class_name} : IHtmlVisitor");
1730    let _ = writeln!(decl, "    {{");
1731
1732    // List of all visitor methods that must be implemented by IHtmlVisitor.
1733    let all_methods = [
1734        "visit_element_start",
1735        "visit_element_end",
1736        "visit_text",
1737        "visit_link",
1738        "visit_image",
1739        "visit_heading",
1740        "visit_code_block",
1741        "visit_code_inline",
1742        "visit_list_item",
1743        "visit_list_start",
1744        "visit_list_end",
1745        "visit_table_start",
1746        "visit_table_row",
1747        "visit_table_end",
1748        "visit_blockquote",
1749        "visit_strong",
1750        "visit_emphasis",
1751        "visit_strikethrough",
1752        "visit_underline",
1753        "visit_subscript",
1754        "visit_superscript",
1755        "visit_mark",
1756        "visit_line_break",
1757        "visit_horizontal_rule",
1758        "visit_custom_element",
1759        "visit_definition_list_start",
1760        "visit_definition_term",
1761        "visit_definition_description",
1762        "visit_definition_list_end",
1763        "visit_form",
1764        "visit_input",
1765        "visit_button",
1766        "visit_audio",
1767        "visit_video",
1768        "visit_iframe",
1769        "visit_details",
1770        "visit_summary",
1771        "visit_figure_start",
1772        "visit_figcaption",
1773        "visit_figure_end",
1774    ];
1775
1776    // Emit all methods: use fixture action if specified, otherwise default to Continue.
1777    for method_name in &all_methods {
1778        if let Some(action) = visitor_spec.callbacks.get(*method_name) {
1779            emit_csharp_visitor_method(&mut decl, method_name, action);
1780        } else {
1781            // Default: Continue for methods not in the fixture
1782            emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
1783        }
1784    }
1785
1786    let _ = writeln!(decl, "    }}");
1787    class_decls.push(decl);
1788
1789    var_name
1790}
1791
1792/// Emit a C# visitor method into a class declaration string.
1793fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
1794    let camel_method = method_to_camel(method_name);
1795    let params = match method_name {
1796        "visit_link" => "NodeContext ctx, string href, string text, string title",
1797        "visit_image" => "NodeContext ctx, string src, string alt, string title",
1798        "visit_heading" => "NodeContext ctx, uint level, string text, string id",
1799        "visit_code_block" => "NodeContext ctx, string lang, string code",
1800        "visit_code_inline"
1801        | "visit_strong"
1802        | "visit_emphasis"
1803        | "visit_strikethrough"
1804        | "visit_underline"
1805        | "visit_subscript"
1806        | "visit_superscript"
1807        | "visit_mark"
1808        | "visit_button"
1809        | "visit_summary"
1810        | "visit_figcaption"
1811        | "visit_definition_term"
1812        | "visit_definition_description" => "NodeContext ctx, string text",
1813        "visit_text" => "NodeContext ctx, string text",
1814        "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
1815        "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
1816        "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
1817        "visit_custom_element" => "NodeContext ctx, string tagName, string html",
1818        "visit_form" => "NodeContext ctx, string actionUrl, string method",
1819        "visit_input" => "NodeContext ctx, string inputType, string name, string value",
1820        "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
1821        "visit_details" => "NodeContext ctx, bool isOpen",
1822        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1823            "NodeContext ctx, string output"
1824        }
1825        "visit_list_start" => "NodeContext ctx, bool ordered",
1826        "visit_list_end" => "NodeContext ctx, bool ordered, string output",
1827        "visit_element_start"
1828        | "visit_table_start"
1829        | "visit_definition_list_start"
1830        | "visit_figure_start"
1831        | "visit_line_break"
1832        | "visit_horizontal_rule" => "NodeContext ctx",
1833        _ => "NodeContext ctx",
1834    };
1835
1836    let _ = writeln!(decl, "        public VisitResult {camel_method}({params})");
1837    let _ = writeln!(decl, "        {{");
1838    match action {
1839        CallbackAction::Skip => {
1840            let _ = writeln!(decl, "            return new VisitResult.Skip();");
1841        }
1842        CallbackAction::Continue => {
1843            let _ = writeln!(decl, "            return new VisitResult.Continue();");
1844        }
1845        CallbackAction::PreserveHtml => {
1846            let _ = writeln!(decl, "            return new VisitResult.PreserveHtml();");
1847        }
1848        CallbackAction::Custom { output } => {
1849            let escaped = escape_csharp(output);
1850            let _ = writeln!(decl, "            return new VisitResult.Custom(\"{escaped}\");");
1851        }
1852        CallbackAction::CustomTemplate { template } => {
1853            let camel = snake_case_template_to_camel(template);
1854            let escaped = escape_csharp(&camel);
1855            let _ = writeln!(decl, "            return new VisitResult.Custom($\"{escaped}\");");
1856        }
1857    }
1858    let _ = writeln!(decl, "        }}");
1859}
1860
1861/// Convert snake_case method names to C# PascalCase.
1862fn method_to_camel(snake: &str) -> String {
1863    use heck::ToUpperCamelCase;
1864    snake.to_upper_camel_case()
1865}
1866
1867/// Rewrite `{snake_case}` placeholders in a custom template to `{camelCase}` so
1868/// they match C# parameter names (which alef emits in camelCase).
1869fn snake_case_template_to_camel(template: &str) -> String {
1870    use heck::ToLowerCamelCase;
1871    let mut out = String::with_capacity(template.len());
1872    let mut chars = template.chars().peekable();
1873    while let Some(c) = chars.next() {
1874        if c == '{' {
1875            let mut name = String::new();
1876            while let Some(&nc) = chars.peek() {
1877                if nc == '}' {
1878                    chars.next();
1879                    break;
1880                }
1881                name.push(nc);
1882                chars.next();
1883            }
1884            out.push('{');
1885            out.push_str(&name.to_lower_camel_case());
1886            out.push('}');
1887        } else {
1888            out.push(c);
1889        }
1890    }
1891    out
1892}
1893
1894/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
1895///
1896/// Maps well-known method names to the appropriate C# static helper calls on the
1897/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
1898fn build_csharp_method_call(
1899    result_var: &str,
1900    method_name: &str,
1901    args: Option<&serde_json::Value>,
1902    class_name: &str,
1903) -> String {
1904    match method_name {
1905        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
1906        "root_node_type" => format!("{result_var}.RootNode.Kind"),
1907        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
1908        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
1909        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
1910        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
1911        "contains_node_type" => {
1912            let node_type = args
1913                .and_then(|a| a.get("node_type"))
1914                .and_then(|v| v.as_str())
1915                .unwrap_or("");
1916            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1917        }
1918        "find_nodes_by_type" => {
1919            let node_type = args
1920                .and_then(|a| a.get("node_type"))
1921                .and_then(|v| v.as_str())
1922                .unwrap_or("");
1923            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1924        }
1925        "run_query" => {
1926            let query_source = args
1927                .and_then(|a| a.get("query_source"))
1928                .and_then(|v| v.as_str())
1929                .unwrap_or("");
1930            let language = args
1931                .and_then(|a| a.get("language"))
1932                .and_then(|v| v.as_str())
1933                .unwrap_or("");
1934            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1935        }
1936        _ => {
1937            use heck::ToUpperCamelCase;
1938            let pascal = method_name.to_upper_camel_case();
1939            format!("{result_var}.{pascal}()")
1940        }
1941    }
1942}
1943
1944fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
1945    // HTTP fixtures are handled separately — not our concern here.
1946    if fixture.is_http_test() {
1947        return false;
1948    }
1949    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1950    let cs_override = call_config
1951        .overrides
1952        .get("csharp")
1953        .or_else(|| e2e_config.call.overrides.get("csharp"));
1954    // When a client_factory is configured the fixture is callable via the client pattern.
1955    if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
1956        return true;
1957    }
1958    // C# binding provides a default class name (e.g., KreuzcrawlLib) if not overridden,
1959    // so any function name makes a callable available.
1960    cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
1961}