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