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