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