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