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        _type_defs: &[alef_core::ir::TypeDef],
34    ) -> Result<Vec<GeneratedFile>> {
35        let lang = self.language_name();
36        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38        let mut files = Vec::new();
39
40        // Resolve call config with overrides.
41        let call = &e2e_config.call;
42        let overrides = call.overrides.get(lang);
43        let function_name = overrides
44            .and_then(|o| o.function.as_ref())
45            .cloned()
46            .unwrap_or_else(|| call.function.to_upper_camel_case());
47        let class_name = overrides
48            .and_then(|o| o.class.as_ref())
49            .cloned()
50            .unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
51        // The exception class is always {CrateName}Exception, generated by the C# backend.
52        let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
53        let namespace = overrides
54            .and_then(|o| o.module.as_ref())
55            .cloned()
56            .or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
57            .unwrap_or_else(|| {
58                if call.module.is_empty() {
59                    "Kreuzberg".to_string()
60                } else {
61                    call.module.to_upper_camel_case()
62                }
63            });
64        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
65        let result_var = &call.result_var;
66        let is_async = call.r#async;
67
68        // Resolve package config.
69        let cs_pkg = e2e_config.resolve_package("csharp");
70        let pkg_name = cs_pkg
71            .as_ref()
72            .and_then(|p| p.name.as_ref())
73            .cloned()
74            .unwrap_or_else(|| config.name.to_upper_camel_case());
75        // Alef scaffolds C# packages as packages/csharp/<Namespace>/<Namespace>.csproj.
76        let pkg_path = cs_pkg
77            .as_ref()
78            .and_then(|p| p.path.as_ref())
79            .cloned()
80            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
81        let pkg_version = cs_pkg
82            .as_ref()
83            .and_then(|p| p.version.as_ref())
84            .cloned()
85            .or_else(|| config.resolved_version())
86            .unwrap_or_else(|| "0.1.0".to_string());
87
88        // Generate the .csproj using a unique name derived from the package name so
89        // it does not conflict with any hand-written project files in the same directory.
90        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
91        files.push(GeneratedFile {
92            path: output_base.join(&csproj_name),
93            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
94            generated_header: false,
95        });
96
97        // Detect whether any fixture needs the mock-server (HTTP fixtures or
98        // fixtures with a `mock_response`). When present, the generated
99        // TestSetup.cs will spawn the mock-server via [ModuleInitializer]
100        // before any test loads — mirroring the Ruby spec_helper / Python
101        // conftest spawn pattern. Without this, every fixture-bound test
102        // failed with `LiterLlmException : builder error` because reqwest
103        // rejected the relative URL when MOCK_SERVER_URL was unset.
104        let needs_mock_server = groups
105            .iter()
106            .flat_map(|g| g.fixtures.iter())
107            .any(|f| f.needs_mock_server());
108
109        // Emit a TestSetup.cs whose ModuleInitializer chdirs to test_documents
110        // so fixture-relative paths like "docx/fake.docx" resolve correctly when
111        // dotnet test runs from bin/{Configuration}/{Tfm}, and (when fixtures
112        // need it) spawns the mock-server binary.
113        files.push(GeneratedFile {
114            path: output_base.join("TestSetup.cs"),
115            content: render_test_setup(needs_mock_server, &e2e_config.test_documents_dir),
116            generated_header: true,
117        });
118
119        // Generate test files per category.
120        let tests_base = output_base.join("tests");
121        let field_resolver = FieldResolver::new(
122            &e2e_config.fields,
123            &e2e_config.fields_optional,
124            &e2e_config.result_fields,
125            &e2e_config.fields_array,
126            &std::collections::HashSet::new(),
127        );
128
129        // Resolve enum_fields and nested_types from C# override config.
130        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
131        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
132
133        // Build effective nested_types by merging defaults with configured overrides.
134        let mut effective_nested_types = default_csharp_nested_types();
135        if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
136            effective_nested_types.extend(overrides_map.clone());
137        }
138
139        for group in groups {
140            let active: Vec<&Fixture> = group
141                .fixtures
142                .iter()
143                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
144                .collect();
145
146            if active.is_empty() {
147                continue;
148            }
149
150            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
151            let filename = format!("{test_class}.cs");
152            let content = render_test_file(
153                &group.category,
154                &active,
155                &namespace,
156                &class_name,
157                &function_name,
158                &exception_class,
159                result_var,
160                &test_class,
161                &e2e_config.call.args,
162                &field_resolver,
163                result_is_simple,
164                is_async,
165                e2e_config,
166                enum_fields,
167                &effective_nested_types,
168            );
169            files.push(GeneratedFile {
170                path: tests_base.join(filename),
171                content,
172                generated_header: true,
173            });
174        }
175
176        Ok(files)
177    }
178
179    fn language_name(&self) -> &'static str {
180        "csharp"
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Rendering
186// ---------------------------------------------------------------------------
187
188fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
189    let pkg_ref = match dep_mode {
190        crate::config::DependencyMode::Registry => {
191            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
192        }
193        crate::config::DependencyMode::Local => {
194            format!("    <ProjectReference Include=\"{pkg_path}\" />")
195        }
196    };
197    crate::template_env::render(
198        "csharp/csproj.jinja",
199        minijinja::context! {
200            pkg_ref => pkg_ref,
201            microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
202            xunit_version => tv::nuget::XUNIT,
203            xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
204        },
205    )
206}
207
208fn render_test_setup(needs_mock_server: bool, test_documents_dir: &str) -> String {
209    let mut out = String::new();
210    out.push_str(&hash::header(CommentStyle::DoubleSlash));
211    out.push_str("using System;\n");
212    out.push_str("using System.IO;\n");
213    if needs_mock_server {
214        out.push_str("using System.Diagnostics;\n");
215    }
216    out.push_str("using System.Runtime.CompilerServices;\n\n");
217    out.push_str("namespace Kreuzberg.E2eTests;\n\n");
218    out.push_str("internal static class TestSetup\n");
219    out.push_str("{\n");
220    if needs_mock_server {
221        out.push_str("    private static Process? _mockServer;\n\n");
222    }
223    out.push_str("    [ModuleInitializer]\n");
224    out.push_str("    internal static void Init()\n");
225    out.push_str("    {\n");
226    let _ = writeln!(
227        out,
228        "        // Walk up from the assembly directory until we find the repo root"
229    );
230    let _ = writeln!(
231        out,
232        "        // (the directory containing {test_documents_dir}/) so that fixture paths"
233    );
234    out.push_str("        // like \"docx/fake.docx\" resolve regardless of where dotnet test\n");
235    out.push_str("        // launched the runner from.\n");
236    out.push_str("        var dir = new DirectoryInfo(AppContext.BaseDirectory);\n");
237    out.push_str("        DirectoryInfo? repoRoot = null;\n");
238    out.push_str("        while (dir != null)\n");
239    out.push_str("        {\n");
240    let _ = writeln!(
241        out,
242        "            var candidate = Path.Combine(dir.FullName, \"{test_documents_dir}\");"
243    );
244    out.push_str("            if (Directory.Exists(candidate))\n");
245    out.push_str("            {\n");
246    out.push_str("                repoRoot = dir;\n");
247    out.push_str("                Directory.SetCurrentDirectory(candidate);\n");
248    out.push_str("                break;\n");
249    out.push_str("            }\n");
250    out.push_str("            dir = dir.Parent;\n");
251    out.push_str("        }\n");
252    if needs_mock_server {
253        out.push('\n');
254        out.push_str("        // Spawn the mock-server binary before any test loads, mirroring the\n");
255        out.push_str("        // Ruby spec_helper / Python conftest pattern. Honors a pre-set\n");
256        out.push_str("        // MOCK_SERVER_URL (e.g. set by `task` or CI) by skipping the spawn.\n");
257        out.push_str("        // Without this, every fixture-bound test failed with\n");
258        out.push_str("        // `<Lib>Exception : builder error` because reqwest rejected the\n");
259        out.push_str("        // relative URL produced by `\"\" + \"/fixtures/<id>\"`.\n");
260        out.push_str("        var preset = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\");\n");
261        out.push_str("        if (!string.IsNullOrEmpty(preset))\n");
262        out.push_str("        {\n");
263        out.push_str("            return;\n");
264        out.push_str("        }\n");
265        out.push_str("        if (repoRoot == null)\n");
266        out.push_str("        {\n");
267        let _ = writeln!(
268            out,
269            "            throw new InvalidOperationException(\"TestSetup: could not locate repo root ({test_documents_dir}/ not found)\");"
270        );
271        out.push_str("        }\n");
272        out.push_str("        var bin = Path.Combine(\n");
273        out.push_str("            repoRoot.FullName,\n");
274        out.push_str("            \"e2e\", \"rust\", \"target\", \"release\", \"mock-server\");\n");
275        out.push_str("        if (OperatingSystem.IsWindows())\n");
276        out.push_str("        {\n");
277        out.push_str("            bin += \".exe\";\n");
278        out.push_str("        }\n");
279        out.push_str("        var fixturesDir = Path.Combine(repoRoot.FullName, \"fixtures\");\n");
280        out.push_str("        if (!File.Exists(bin))\n");
281        out.push_str("        {\n");
282        out.push_str("            throw new InvalidOperationException(\n");
283        out.push_str("                $\"TestSetup: mock-server binary not found at {bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release\");\n");
284        out.push_str("        }\n");
285        out.push_str("        var psi = new ProcessStartInfo\n");
286        out.push_str("        {\n");
287        out.push_str("            FileName = bin,\n");
288        out.push_str("            Arguments = $\"\\\"{fixturesDir}\\\"\",\n");
289        out.push_str("            RedirectStandardInput = true,\n");
290        out.push_str("            RedirectStandardOutput = true,\n");
291        out.push_str("            RedirectStandardError = true,\n");
292        out.push_str("            UseShellExecute = false,\n");
293        out.push_str("        };\n");
294        out.push_str("        _mockServer = Process.Start(psi)\n");
295        out.push_str(
296            "            ?? throw new InvalidOperationException(\"TestSetup: failed to start mock-server\");\n",
297        );
298        out.push_str("        // The mock-server prints MOCK_SERVER_URL=<url>, then optionally\n");
299        out.push_str("        // MOCK_SERVERS={...} for host-root fixtures. Read up to 16 lines.\n");
300        out.push_str("        string? url = null;\n");
301        out.push_str("        for (int i = 0; i < 16; i++)\n");
302        out.push_str("        {\n");
303        out.push_str("            var line = _mockServer.StandardOutput.ReadLine();\n");
304        out.push_str("            if (line == null)\n");
305        out.push_str("            {\n");
306        out.push_str("                break;\n");
307        out.push_str("            }\n");
308        out.push_str("            const string urlPrefix = \"MOCK_SERVER_URL=\";\n");
309        out.push_str("            const string serversPrefix = \"MOCK_SERVERS=\";\n");
310        out.push_str("            if (line.StartsWith(urlPrefix, StringComparison.Ordinal))\n");
311        out.push_str("            {\n");
312        out.push_str("                url = line.Substring(urlPrefix.Length).Trim();\n");
313        out.push_str("            }\n");
314        out.push_str("            else if (line.StartsWith(serversPrefix, StringComparison.Ordinal))\n");
315        out.push_str("            {\n");
316        out.push_str("                var jsonVal = line.Substring(serversPrefix.Length).Trim();\n");
317        out.push_str("                Environment.SetEnvironmentVariable(\"MOCK_SERVERS\", jsonVal);\n");
318        out.push_str("                // Parse JSON map and set per-fixture env vars (MOCK_SERVER_<FIXTURE_ID>).\n");
319        out.push_str("                var matches = System.Text.RegularExpressions.Regex.Matches(\n");
320        out.push_str("                    jsonVal, \"\\\"([^\\\"]+)\\\":\\\"([^\\\"]+)\\\"\");\n");
321        out.push_str("                foreach (System.Text.RegularExpressions.Match m in matches)\n");
322        out.push_str("                {\n");
323        out.push_str("                    Environment.SetEnvironmentVariable(\n");
324        out.push_str("                        \"MOCK_SERVER_\" + m.Groups[1].Value.ToUpperInvariant(),\n");
325        out.push_str("                        m.Groups[2].Value);\n");
326        out.push_str("                }\n");
327        out.push_str("                break;\n");
328        out.push_str("            }\n");
329        out.push_str("            else if (url != null)\n");
330        out.push_str("            {\n");
331        out.push_str("                break;\n");
332        out.push_str("            }\n");
333        out.push_str("        }\n");
334        out.push_str("        if (string.IsNullOrEmpty(url))\n");
335        out.push_str("        {\n");
336        out.push_str("            try { _mockServer.Kill(true); } catch { }\n");
337        out.push_str("            throw new InvalidOperationException(\"TestSetup: mock-server did not emit MOCK_SERVER_URL\");\n");
338        out.push_str("        }\n");
339        out.push_str("        Environment.SetEnvironmentVariable(\"MOCK_SERVER_URL\", url);\n");
340        out.push_str("        // TCP-readiness probe: ensure axum::serve is accepting before tests start.\n");
341        out.push_str("        // The mock-server binds the TcpListener synchronously then prints the URL\n");
342        out.push_str("        // before tokio::spawn(axum::serve(...)) is polled, so under xUnit\n");
343        out.push_str("        // class-parallel default tests can race startup. Poll-connect (max 5s,\n");
344        out.push_str("        // 50ms backoff) until success.\n");
345        out.push_str("        var healthUri = new System.Uri(url);\n");
346        out.push_str("        var deadline = System.Diagnostics.Stopwatch.StartNew();\n");
347        out.push_str("        while (deadline.ElapsedMilliseconds < 5000)\n");
348        out.push_str("        {\n");
349        out.push_str("            try\n");
350        out.push_str("            {\n");
351        out.push_str("                using var probe = new System.Net.Sockets.TcpClient();\n");
352        out.push_str("                var task = probe.ConnectAsync(healthUri.Host, healthUri.Port);\n");
353        out.push_str("                if (task.Wait(100) && probe.Connected) { break; }\n");
354        out.push_str("            }\n");
355        out.push_str("            catch (System.Exception) { }\n");
356        out.push_str("            System.Threading.Thread.Sleep(50);\n");
357        out.push_str("        }\n");
358        out.push_str("        // Drain stdout/stderr so the child does not block on a full pipe.\n");
359        out.push_str("        var server = _mockServer;\n");
360        out.push_str("        var stdoutThread = new System.Threading.Thread(() =>\n");
361        out.push_str("        {\n");
362        out.push_str("            try { server.StandardOutput.ReadToEnd(); } catch { }\n");
363        out.push_str("        }) { IsBackground = true };\n");
364        out.push_str("        stdoutThread.Start();\n");
365        out.push_str("        var stderrThread = new System.Threading.Thread(() =>\n");
366        out.push_str("        {\n");
367        out.push_str("            try { server.StandardError.ReadToEnd(); } catch { }\n");
368        out.push_str("        }) { IsBackground = true };\n");
369        out.push_str("        stderrThread.Start();\n");
370        out.push_str("        // Tear the child down on assembly unload / process exit by closing\n");
371        out.push_str("        // its stdin (the mock-server treats stdin EOF as a shutdown signal).\n");
372        out.push_str("        AppDomain.CurrentDomain.ProcessExit += (_, _) =>\n");
373        out.push_str("        {\n");
374        out.push_str("            try { _mockServer.StandardInput.Close(); } catch { }\n");
375        out.push_str("            try { if (!_mockServer.WaitForExit(2000)) { _mockServer.Kill(true); } } catch { }\n");
376        out.push_str("        };\n");
377    }
378    out.push_str("    }\n");
379    out.push_str("}\n");
380    out
381}
382
383#[allow(clippy::too_many_arguments)]
384fn render_test_file(
385    category: &str,
386    fixtures: &[&Fixture],
387    namespace: &str,
388    class_name: &str,
389    function_name: &str,
390    exception_class: &str,
391    result_var: &str,
392    test_class: &str,
393    args: &[crate::config::ArgMapping],
394    field_resolver: &FieldResolver,
395    result_is_simple: bool,
396    is_async: bool,
397    e2e_config: &E2eConfig,
398    enum_fields: &HashMap<String, String>,
399    nested_types: &HashMap<String, String>,
400) -> String {
401    // Collect using imports
402    let mut using_imports = String::new();
403    using_imports.push_str("using System;\n");
404    using_imports.push_str("using System.Collections.Generic;\n");
405    using_imports.push_str("using System.Linq;\n");
406    using_imports.push_str("using System.Net.Http;\n");
407    using_imports.push_str("using System.Text;\n");
408    using_imports.push_str("using System.Text.Json;\n");
409    using_imports.push_str("using System.Text.Json.Serialization;\n");
410    using_imports.push_str("using System.Threading.Tasks;\n");
411    using_imports.push_str("using Xunit;\n");
412    using_imports.push_str(&format!("using {namespace};\n"));
413    using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));
414
415    // Shared options field
416    let config_options_field = "    private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
417
418    // Visitor class declarations accumulated across all fixtures — emitted as
419    // private nested classes inside the test class but outside any method body.
420    // C# does not allow local class declarations inside method bodies.
421    let mut visitor_class_decls: Vec<String> = Vec::new();
422
423    // Build fixtures body
424    let mut fixtures_body = String::new();
425    for (i, fixture) in fixtures.iter().enumerate() {
426        render_test_method(
427            &mut fixtures_body,
428            &mut visitor_class_decls,
429            fixture,
430            class_name,
431            function_name,
432            exception_class,
433            result_var,
434            args,
435            field_resolver,
436            result_is_simple,
437            is_async,
438            e2e_config,
439            enum_fields,
440            nested_types,
441        );
442        if i + 1 < fixtures.len() {
443            fixtures_body.push('\n');
444        }
445    }
446
447    // Build visitor classes string
448    let mut visitor_classes_str = String::new();
449    for (i, decl) in visitor_class_decls.iter().enumerate() {
450        if i > 0 {
451            visitor_classes_str.push('\n');
452        }
453        visitor_classes_str.push('\n');
454        // Indent each line by 4 spaces to nest inside the test class
455        for line in decl.lines() {
456            visitor_classes_str.push_str("    ");
457            visitor_classes_str.push_str(line);
458            visitor_classes_str.push('\n');
459        }
460    }
461
462    let ctx = minijinja::context! {
463        header => hash::header(CommentStyle::DoubleSlash),
464        using_imports => using_imports,
465        category => category,
466        namespace => namespace,
467        test_class => test_class,
468        config_options_field => config_options_field,
469        fixtures_body => fixtures_body,
470        visitor_class_decls => visitor_classes_str,
471    };
472
473    crate::template_env::render("csharp/test_file.jinja", ctx)
474}
475
476// ---------------------------------------------------------------------------
477// HTTP test rendering — shared-driver integration
478// ---------------------------------------------------------------------------
479
480/// Renderer that emits xUnit `[Fact] public async Task Test_*()` methods using
481/// `System.Net.Http.HttpClient` against the mock server at `MOCK_SERVER_URL`.
482/// Satisfies [`client::TestClientRenderer`] so the shared
483/// [`client::http_call::render_http_test`] driver drives the call sequence.
484struct CSharpTestClientRenderer;
485
486/// C# HttpMethod static properties are PascalCase (Get, Post, Put, Delete, …).
487fn to_csharp_http_method(method: &str) -> String {
488    let lower = method.to_ascii_lowercase();
489    let mut chars = lower.chars();
490    match chars.next() {
491        Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
492        None => String::new(),
493    }
494}
495
496/// Headers that belong to `request.Content.Headers` rather than `request.Headers`.
497///
498/// Adding these to `request.Headers` causes .NET to throw "Misused header name".
499const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
500    "content-length",
501    "host",
502    "connection",
503    "expect",
504    "transfer-encoding",
505    "upgrade",
506    // Content-Type is owned by request.Content.Headers and is set when
507    // StringContent is constructed; adding it to request.Headers throws.
508    "content-type",
509    // Other entity headers also belong to request.Content.Headers.
510    "content-encoding",
511    "content-language",
512    "content-location",
513    "content-md5",
514    "content-range",
515    "content-disposition",
516];
517
518/// Whether `name` (any case) belongs to `response.Content.Headers` rather than
519/// `response.Headers`. Picking the wrong collection causes .NET to throw
520/// "Misused header name".
521fn is_csharp_content_header(name: &str) -> bool {
522    matches!(
523        name.to_ascii_lowercase().as_str(),
524        "content-type"
525            | "content-length"
526            | "content-encoding"
527            | "content-language"
528            | "content-location"
529            | "content-md5"
530            | "content-range"
531            | "content-disposition"
532            | "expires"
533            | "last-modified"
534            | "allow"
535    )
536}
537
538impl client::TestClientRenderer for CSharpTestClientRenderer {
539    fn language_name(&self) -> &'static str {
540        "csharp"
541    }
542
543    /// Convert a fixture id to the PascalCase identifier used in `Test_{name}`.
544    fn sanitize_test_name(&self, id: &str) -> String {
545        id.to_upper_camel_case()
546    }
547
548    /// Emit `[Fact]` (or `[Fact(Skip = "…")]` for skipped tests), the method
549    /// signature, the opening brace, and the description comment.
550    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
551        let escaped_reason = skip_reason.map(escape_csharp);
552        let rendered = crate::template_env::render(
553            "csharp/http_test_open.jinja",
554            minijinja::context! {
555                fn_name => fn_name,
556                description => description,
557                skip_reason => escaped_reason,
558            },
559        );
560        out.push_str(&rendered);
561    }
562
563    /// Emit the closing `}` for a test method.
564    fn render_test_close(&self, out: &mut String) {
565        let rendered = crate::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
566        out.push_str(&rendered);
567    }
568
569    /// Emit the `HttpRequestMessage` construction, headers, cookies, body, and
570    /// `var response = await client.SendAsync(request)`.
571    ///
572    /// The fixture path follows the mock-server convention `/fixtures/<id>`.
573    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
574        let method = to_csharp_http_method(ctx.method);
575        let path = escape_csharp(ctx.path);
576
577        out.push_str("        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
578        // Disable auto-follow so redirect-status fixtures (3xx) can assert the
579        // server's status code rather than the followed-target's status.
580        out.push_str(
581            "        using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
582        );
583        out.push_str("        using var client = new System.Net.Http.HttpClient(handler);\n");
584        out.push_str(&format!("        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));
585
586        // Set body + Content-Type when a request body is present.
587        if let Some(body) = ctx.body {
588            let content_type = ctx.content_type.unwrap_or("application/json");
589            let json_str = serde_json::to_string(body).unwrap_or_default();
590            let escaped = escape_csharp(&json_str);
591            out.push_str(&format!("        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
592        }
593
594        // Add request headers (skip restricted headers that belong to Content.Headers).
595        for (name, value) in ctx.headers {
596            if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
597                continue;
598            }
599            let escaped_name = escape_csharp(name);
600            let escaped_value = escape_csharp(value);
601            out.push_str(&format!(
602                "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
603            ));
604        }
605
606        // Combine cookies into a single `Cookie` header.
607        if !ctx.cookies.is_empty() {
608            let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
609            pairs.sort();
610            let cookie_header = escape_csharp(&pairs.join("; "));
611            out.push_str(&format!(
612                "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
613            ));
614        }
615
616        out.push_str("        var response = await client.SendAsync(request);\n");
617    }
618
619    /// Emit `Assert.Equal(status, (int)response.StatusCode)`.
620    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
621        out.push_str(&format!("        Assert.Equal({status}, (int)response.StatusCode);\n"));
622    }
623
624    /// Emit a response-header assertion.
625    ///
626    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
627    /// Picks `response.Content.Headers` vs `response.Headers` based on the header name.
628    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
629        let target = if is_csharp_content_header(name) {
630            "response.Content.Headers"
631        } else {
632            "response.Headers"
633        };
634        let escaped_name = escape_csharp(name);
635        match expected {
636            "<<present>>" => {
637                out.push_str(&format!("        Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
638            }
639            "<<absent>>" => {
640                out.push_str(&format!("        Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
641            }
642            "<<uuid>>" => {
643                // UUID regex: 8-4-4-4-12 hex groups.
644                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"));
645            }
646            literal => {
647                // Use a deterministic local-variable name derived from the header name so
648                // multiple header assertions in the same method body do not redeclare.
649                let var_name = format!("hdr{}", sanitize_ident(name));
650                let escaped_value = escape_csharp(literal);
651                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"));
652            }
653        }
654    }
655
656    /// Emit a JSON body equality assertion via `JsonDocument`.
657    ///
658    /// Plain-string bodies are compared with `Assert.Equal` after trimming.
659    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
660        match expected {
661            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
662                let json_str = serde_json::to_string(expected).unwrap_or_default();
663                let escaped = escape_csharp(&json_str);
664                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
665                out.push_str("        var body = JsonDocument.Parse(bodyText).RootElement;\n");
666                out.push_str(&format!(
667                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
668                ));
669                out.push_str("        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
670            }
671            serde_json::Value::String(s) => {
672                let escaped = escape_csharp(s);
673                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
674                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
675            }
676            other => {
677                let escaped = escape_csharp(&other.to_string());
678                out.push_str("        var bodyText = await response.Content.ReadAsStringAsync();\n");
679                out.push_str(&format!("        Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
680            }
681        }
682    }
683
684    /// Emit per-field equality assertions for a partial body match.
685    ///
686    /// Uses a separate `partialBodyText` local so it does not collide with
687    /// `bodyText` if `render_assert_json_body` was also called.
688    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
689        if let Some(obj) = expected.as_object() {
690            out.push_str("        var partialBodyText = await response.Content.ReadAsStringAsync();\n");
691            out.push_str("        var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
692            for (key, val) in obj {
693                let escaped_key = escape_csharp(key);
694                let json_str = serde_json::to_string(val).unwrap_or_default();
695                let escaped_val = escape_csharp(&json_str);
696                let var_name = format!("expected{}", key.to_upper_camel_case());
697                out.push_str(&format!(
698                    "        var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
699                ));
700                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"));
701            }
702        }
703    }
704
705    /// Emit validation-error assertions by checking each expected `msg` string
706    /// appears in the JSON-encoded body.
707    fn render_assert_validation_errors(
708        &self,
709        out: &mut String,
710        _response_var: &str,
711        errors: &[ValidationErrorExpectation],
712    ) {
713        out.push_str("        var validationBodyText = await response.Content.ReadAsStringAsync();\n");
714        for err in errors {
715            let escaped_msg = escape_csharp(&err.msg);
716            out.push_str(&format!(
717                "        Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
718            ));
719        }
720    }
721}
722
723/// Render an HTTP server test method using the shared [`client::http_call::render_http_test`]
724/// driver via [`CSharpTestClientRenderer`].
725fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
726    client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
727}
728
729#[allow(clippy::too_many_arguments)]
730fn render_test_method(
731    out: &mut String,
732    visitor_class_decls: &mut Vec<String>,
733    fixture: &Fixture,
734    class_name: &str,
735    _function_name: &str,
736    exception_class: &str,
737    _result_var: &str,
738    _args: &[crate::config::ArgMapping],
739    field_resolver: &FieldResolver,
740    result_is_simple: bool,
741    _is_async: bool,
742    e2e_config: &E2eConfig,
743    enum_fields: &HashMap<String, String>,
744    nested_types: &HashMap<String, String>,
745) {
746    let method_name = fixture.id.to_upper_camel_case();
747    let description = &fixture.description;
748
749    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
750    if let Some(http) = &fixture.http {
751        render_http_test_method(out, fixture, http);
752        return;
753    }
754
755    // Non-HTTP fixtures with no mock_response: skip only if the C# binding
756    // does not have a callable for this function via [e2e.call.overrides.csharp].
757    if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
758        let skip_reason =
759            "non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
760        let ctx = minijinja::context! {
761            is_skipped => true,
762            skip_reason => skip_reason,
763            description => description,
764            method_name => method_name,
765        };
766        let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
767        out.push_str(&rendered);
768        return;
769    }
770
771    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
772
773    // Resolve call config per-fixture so named calls (e.g. "parse") use the
774    // correct function name, result variable, and async flag.
775    // Use resolve_call_for_fixture to support auto-routing via select_when.
776    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
777    let lang = "csharp";
778    let cs_overrides = call_config.overrides.get(lang);
779
780    // Streaming branch: chat_stream returns IAsyncEnumerable<ChatCompletionChunk>,
781    // not Task<T>. Emit `await foreach` over the stream, building local
782    // aggregator vars (`chunks`, `streamContent`, `streamComplete`, ...) and
783    // asserting on those locals — never on response pseudo-fields.
784    let raw_function_name = cs_overrides
785        .and_then(|o| o.function.as_ref())
786        .cloned()
787        .unwrap_or_else(|| call_config.function.clone());
788    if raw_function_name == "chat_stream" {
789        render_chat_stream_test_method(
790            out,
791            fixture,
792            class_name,
793            call_config,
794            cs_overrides,
795            e2e_config,
796            enum_fields,
797            nested_types,
798            exception_class,
799        );
800        return;
801    }
802
803    let effective_function_name = cs_overrides
804        .and_then(|o| o.function.as_ref())
805        .cloned()
806        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
807    let effective_result_var = &call_config.result_var;
808    let effective_is_async = call_config.r#async;
809    let function_name = effective_function_name.as_str();
810    let result_var = effective_result_var.as_str();
811    let is_async = effective_is_async;
812    let args = call_config.args.as_slice();
813
814    // Per-call overrides: result shape, void returns, extra trailing args.
815    // Pull `result_is_simple` from the per-call config first (call-level value
816    // wins, then per-language override, then the top-level call's value).
817    let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
818    // result_is_bytes: when set, the call returns a raw byte[] in C# (not a
819    // struct with named fields). Mirrors the C codegen flag added in 50e1d309.
820    // Treat byte-buffer returns like result_is_simple (no struct accessors) and
821    // emit byte-specific assertions for `not_empty`/`is_empty`.
822    let per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
823    let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
824    let effective_result_is_bytes = per_call_result_is_bytes;
825    let returns_void = call_config.returns_void;
826    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
827    // options_type: prefer per-call override, fall back to top-level csharp override.
828    let top_level_options_type = e2e_config
829        .call
830        .overrides
831        .get("csharp")
832        .and_then(|o| o.options_type.as_deref());
833    let effective_options_type = cs_overrides
834        .and_then(|o| o.options_type.as_deref())
835        .or(top_level_options_type);
836
837    // options_via: how to construct the options object. Supported values:
838    //   "kwargs" (default) — emit a C# object initializer (`new T { ... }`).
839    //   "from_json"        — emit `JsonSerializer.Deserialize<T>(json, ConfigOptions)!`,
840    //                        sidestepping per-field type ambiguity for fields like
841    //                        `JsonElement?` (untagged unions) or `List<NamedRecord>`
842    //                        (where the codegen would otherwise default to `List<string>`).
843    let top_level_options_via = e2e_config
844        .call
845        .overrides
846        .get("csharp")
847        .and_then(|o| o.options_via.as_deref());
848    let effective_options_via = cs_overrides
849        .and_then(|o| o.options_via.as_deref())
850        .or(top_level_options_via);
851
852    let (mut setup_lines, args_str) = build_args_and_setup(
853        &fixture.input,
854        args,
855        class_name,
856        effective_options_type,
857        effective_options_via,
858        enum_fields,
859        nested_types,
860        fixture,
861    );
862
863    // Build visitor if present: instantiate in method body, declare class at file scope.
864    let mut visitor_arg = String::new();
865    let has_visitor = fixture.visitor.is_some();
866    if let Some(visitor_spec) = &fixture.visitor {
867        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
868    }
869
870    // When a visitor is present, embed it in the options object instead of passing as a separate arg.
871    // args_str should contain the function arguments with null for missing options (e.g., "html, null").
872    // We need to replace that null with a ConversionOptions instance that has Visitor set.
873    let final_args = if has_visitor && !visitor_arg.is_empty() {
874        let opts_type = effective_options_type.unwrap_or("ConversionOptions");
875        if args_str.contains("JsonSerializer.Deserialize") {
876            // Deserialize form: extract the deserialized object and set Visitor on it
877            setup_lines.push(format!("var options = {args_str};"));
878            setup_lines.push(format!("options.Visitor = {visitor_arg};"));
879            "options".to_string()
880        } else if args_str.ends_with(", null") {
881            // Replace trailing ", null" with options
882            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
883            let trimmed = args_str[..args_str.len() - 6].to_string(); // Remove ", null" (6 chars including space)
884            format!("{trimmed}, options")
885        } else if args_str.contains(", null,") {
886            // Options parameter is null in the middle; replace it
887            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
888            args_str.replace(", null,", ", options,")
889        } else if args_str.is_empty() {
890            // No options were provided; create new instance with Visitor
891            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
892            "options".to_string()
893        } else {
894            // Fall back to appending options
895            setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
896            format!("{args_str}, options")
897        }
898    } else if extra_args_slice.is_empty() {
899        args_str
900    } else if args_str.is_empty() {
901        extra_args_slice.join(", ")
902    } else {
903        format!("{args_str}, {}", extra_args_slice.join(", "))
904    };
905
906    // Always use the base function name (Convert) regardless of visitor presence
907    // The visitor is now handled internally via options.Visitor
908    let effective_function_name = function_name.to_string();
909
910    let return_type = if is_async { "async Task" } else { "void" };
911    let await_kw = if is_async { "await " } else { "" };
912
913    // Client factory: when set, create a client instance and call methods on it
914    // rather than using static class calls.
915    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
916        e2e_config
917            .call
918            .overrides
919            .get("csharp")
920            .and_then(|o| o.client_factory.as_deref())
921    });
922    let call_target = if client_factory.is_some() {
923        "client".to_string()
924    } else {
925        class_name.to_string()
926    };
927
928    // Build client factory setup code. For fixtures whose env block sets
929    // an `api_key_var` AND that have neither `mock_response` nor an `http`
930    // override (live-smoke tests against real provider APIs), prepend an
931    // early-return when the env var is unset so CI without API keys does
932    // not fail with `not found: No mock route for ...`. Mirrors the
933    // Elixir / Python skip pattern documented in CHANGELOG v0.15.9.
934    let mut client_factory_setup = String::new();
935    if let Some(factory) = client_factory {
936        let factory_name = factory.to_upper_camel_case();
937        let fixture_id = &fixture.id;
938        let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
939        let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
940        let is_live_smoke = !has_mock && api_key_var_opt.is_some();
941        if has_mock && api_key_var_opt.is_some() {
942            let api_key_var = api_key_var_opt.unwrap();
943            client_factory_setup.push_str(&format!(
944                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
945            ));
946            client_factory_setup.push_str(&format!(
947                "        var baseUrl = string.IsNullOrEmpty(apiKey)\n            ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n            : null;\n"
948            ));
949            client_factory_setup.push_str(&format!(
950                "        Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
951            ));
952            client_factory_setup.push_str(&format!(
953                "        var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
954            ));
955        } else if is_live_smoke {
956            let api_key_var = api_key_var_opt.unwrap();
957            client_factory_setup.push_str(&format!(
958                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
959            ));
960            client_factory_setup.push_str("        if (string.IsNullOrEmpty(apiKey)) { return; }\n");
961            client_factory_setup.push_str(&format!(
962                "        var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
963            ));
964        } else if fixture.has_host_root_route() {
965            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
966            client_factory_setup.push_str(&format!(
967                "        var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
968            ));
969            client_factory_setup.push_str(&format!("        var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
970            client_factory_setup.push_str(&format!(
971                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
972            ));
973        } else {
974            client_factory_setup.push_str(&format!("        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
975            client_factory_setup.push_str(&format!(
976                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
977            ));
978        }
979    }
980
981    // Build call expression
982    let call_expr = format!("{}({})", effective_function_name, final_args);
983
984    // Merge per-call C# `enum_fields` keys with the global file-level
985    // `fields_enum` set so call-specific enum-typed result fields (e.g.
986    // BatchObject's `status` → BatchStatus) trigger enum coercion in
987    // assertions even when the global set does not list them. The
988    // file-level `enum_fields` argument carries the default-call's override;
989    // `cs_overrides.enum_fields` carries the per-fixture-call's override
990    // (e.g. retrieve_batch.overrides.csharp.enum_fields).
991    let mut effective_enum_fields: std::collections::HashSet<String> = e2e_config.fields_enum.clone();
992    for k in enum_fields.keys() {
993        effective_enum_fields.insert(k.clone());
994    }
995    if let Some(o) = cs_overrides {
996        for k in o.enum_fields.keys() {
997            effective_enum_fields.insert(k.clone());
998        }
999    }
1000
1001    // Build assertions body for non-error cases
1002    let mut assertions_body = String::new();
1003    if !expects_error && !returns_void {
1004        for assertion in &fixture.assertions {
1005            render_assertion(
1006                &mut assertions_body,
1007                assertion,
1008                result_var,
1009                class_name,
1010                exception_class,
1011                field_resolver,
1012                effective_result_is_simple,
1013                call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
1014                call_config.result_is_array,
1015                effective_result_is_bytes,
1016                &effective_enum_fields,
1017            );
1018        }
1019    }
1020
1021    let ctx = minijinja::context! {
1022        is_skipped => false,
1023        expects_error => expects_error,
1024        description => description,
1025        return_type => return_type,
1026        method_name => method_name,
1027        async_kw => await_kw,
1028        call_target => call_target,
1029        setup_lines => setup_lines.clone(),
1030        call_expr => call_expr,
1031        exception_class => exception_class,
1032        client_factory_setup => client_factory_setup,
1033        has_usable_assertion => !expects_error && !returns_void,
1034        result_var => result_var,
1035        assertions_body => assertions_body,
1036    };
1037
1038    let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
1039    // Indent each line by 4 spaces to nest inside the test class
1040    for line in rendered.lines() {
1041        out.push_str("    ");
1042        out.push_str(line);
1043        out.push('\n');
1044    }
1045}
1046
1047/// Render a `chat_stream` test method. The C# binding emits
1048/// `IAsyncEnumerable<ChatCompletionChunk> ChatStream(req)` (not `Task<T>`), so
1049/// the test body uses `await foreach` to drive the stream and aggregates
1050/// per-chunk data into local vars (`chunks`, `streamContent`, `streamComplete`,
1051/// optional `lastFinishReason`/`toolCallsJson`/`toolCalls0FunctionName`/`totalTokens`).
1052/// Assertions then run against those locals — never against pseudo-fields on a
1053/// response object.
1054#[allow(clippy::too_many_arguments)]
1055fn render_chat_stream_test_method(
1056    out: &mut String,
1057    fixture: &Fixture,
1058    class_name: &str,
1059    call_config: &crate::config::CallConfig,
1060    cs_overrides: Option<&crate::config::CallOverride>,
1061    e2e_config: &E2eConfig,
1062    enum_fields: &HashMap<String, String>,
1063    nested_types: &HashMap<String, String>,
1064    exception_class: &str,
1065) {
1066    let method_name = fixture.id.to_upper_camel_case();
1067    let description = &fixture.description;
1068    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1069
1070    let effective_function_name = cs_overrides
1071        .and_then(|o| o.function.as_ref())
1072        .cloned()
1073        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
1074    let function_name = effective_function_name.as_str();
1075    let args = call_config.args.as_slice();
1076
1077    let top_level_options_type = e2e_config
1078        .call
1079        .overrides
1080        .get("csharp")
1081        .and_then(|o| o.options_type.as_deref());
1082    let effective_options_type = cs_overrides
1083        .and_then(|o| o.options_type.as_deref())
1084        .or(top_level_options_type);
1085    let top_level_options_via = e2e_config
1086        .call
1087        .overrides
1088        .get("csharp")
1089        .and_then(|o| o.options_via.as_deref());
1090    let effective_options_via = cs_overrides
1091        .and_then(|o| o.options_via.as_deref())
1092        .or(top_level_options_via);
1093
1094    let (setup_lines, args_str) = build_args_and_setup(
1095        &fixture.input,
1096        args,
1097        class_name,
1098        effective_options_type,
1099        effective_options_via,
1100        enum_fields,
1101        nested_types,
1102        fixture,
1103    );
1104
1105    let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1106        e2e_config
1107            .call
1108            .overrides
1109            .get("csharp")
1110            .and_then(|o| o.client_factory.as_deref())
1111    });
1112    let mut client_factory_setup = String::new();
1113    if let Some(factory) = client_factory {
1114        let factory_name = factory.to_upper_camel_case();
1115        let fixture_id = &fixture.id;
1116        let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
1117        let api_key_var_opt = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
1118        let is_live_smoke = !has_mock && api_key_var_opt.is_some();
1119        if has_mock && api_key_var_opt.is_some() {
1120            let api_key_var = api_key_var_opt.unwrap();
1121            client_factory_setup.push_str(&format!(
1122                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1123            ));
1124            client_factory_setup.push_str(&format!(
1125                "        var baseUrl = string.IsNullOrEmpty(apiKey)\n            ? (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\"\n            : null;\n"
1126            ));
1127            client_factory_setup.push_str(&format!(
1128                "        Console.WriteLine($\"{fixture_id}: \" + (baseUrl == null ? \"using real API ({api_key_var} is set)\" : \"using mock server ({api_key_var} not set)\"));\n"
1129            ));
1130            client_factory_setup.push_str(&format!(
1131                "        var client = {class_name}.{factory_name}(string.IsNullOrEmpty(apiKey) ? \"test-key\" : apiKey, baseUrl, null, null, null);\n"
1132            ));
1133        } else if is_live_smoke {
1134            let api_key_var = api_key_var_opt.unwrap();
1135            client_factory_setup.push_str(&format!(
1136                "        var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1137            ));
1138            client_factory_setup.push_str("        if (string.IsNullOrEmpty(apiKey)) { return; }\n");
1139            client_factory_setup.push_str(&format!(
1140                "        var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
1141            ));
1142        } else if fixture.has_host_root_route() {
1143            let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1144            client_factory_setup.push_str(&format!(
1145                "        var _perFixtureUrl = System.Environment.GetEnvironmentVariable(\"{env_key}\");\n"
1146            ));
1147            client_factory_setup.push_str(&format!("        var baseUrl = !string.IsNullOrEmpty(_perFixtureUrl) ? _perFixtureUrl : (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
1148            client_factory_setup.push_str(&format!(
1149                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1150            ));
1151        } else {
1152            client_factory_setup.push_str(&format!(
1153                "        var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"
1154            ));
1155            client_factory_setup.push_str(&format!(
1156                "        var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1157            ));
1158        }
1159    }
1160
1161    let call_target = if client_factory.is_some() { "client" } else { class_name };
1162    let call_expr = format!("{call_target}.{function_name}({args_str})");
1163
1164    // Detect which aggregators a fixture's assertions actually need.
1165    let mut needs_finish_reason = false;
1166    let mut needs_tool_calls_json = false;
1167    let mut needs_tool_calls_0_function_name = false;
1168    let mut needs_total_tokens = false;
1169    for a in &fixture.assertions {
1170        if let Some(f) = a.field.as_deref() {
1171            match f {
1172                "finish_reason" => needs_finish_reason = true,
1173                "tool_calls" => needs_tool_calls_json = true,
1174                "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
1175                "usage.total_tokens" => needs_total_tokens = true,
1176                _ => {}
1177            }
1178        }
1179    }
1180
1181    let mut body = String::new();
1182    let _ = writeln!(body, "    [Fact]");
1183    let _ = writeln!(body, "    public async Task Test_{method_name}()");
1184    let _ = writeln!(body, "    {{");
1185    let _ = writeln!(body, "        // {description}");
1186    if !client_factory_setup.is_empty() {
1187        body.push_str(&client_factory_setup);
1188    }
1189    for line in &setup_lines {
1190        let _ = writeln!(body, "        {line}");
1191    }
1192
1193    if expects_error {
1194        // Wrap the foreach in a lambda so the IAsyncEnumerable is actually
1195        // consumed (otherwise the producer never runs and no exception is raised).
1196        let _ = writeln!(
1197            body,
1198            "        await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
1199        );
1200        let _ = writeln!(body, "            await foreach (var _chunk in {call_expr}) {{ }}");
1201        body.push_str("        });\n");
1202        body.push_str("    }\n");
1203        for line in body.lines() {
1204            out.push_str("    ");
1205            out.push_str(line);
1206            out.push('\n');
1207        }
1208        return;
1209    }
1210
1211    body.push_str("        var chunks = new List<ChatCompletionChunk>();\n");
1212    body.push_str("        var streamContent = new System.Text.StringBuilder();\n");
1213    body.push_str("        var streamComplete = false;\n");
1214    if needs_finish_reason {
1215        body.push_str("        string? lastFinishReason = null;\n");
1216    }
1217    if needs_tool_calls_json {
1218        body.push_str("        string? toolCallsJson = null;\n");
1219    }
1220    if needs_tool_calls_0_function_name {
1221        body.push_str("        string? toolCalls0FunctionName = null;\n");
1222    }
1223    if needs_total_tokens {
1224        body.push_str("        long? totalTokens = null;\n");
1225    }
1226    let _ = writeln!(body, "        await foreach (var chunk in {call_expr})");
1227    body.push_str("        {\n");
1228    body.push_str("            chunks.Add(chunk);\n");
1229    body.push_str(
1230        "            var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
1231    );
1232    body.push_str("            if (choice != null)\n");
1233    body.push_str("            {\n");
1234    body.push_str("                var delta = choice.Delta;\n");
1235    body.push_str("                if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
1236    body.push_str("                {\n");
1237    body.push_str("                    streamContent.Append(delta.Content);\n");
1238    body.push_str("                }\n");
1239    if needs_finish_reason {
1240        // Streaming accumulator must use the wire-form snake_case representation
1241        // (e.g. `tool_calls`) so equality assertions against the fixture-side
1242        // string match. `.ToString().ToLower()` collapses compound PascalCase
1243        // names like `ToolCalls` to `toolcalls` (no underscore), causing
1244        // assertion failures. `JsonNamingPolicy.SnakeCaseLower.ConvertName`
1245        // mirrors the policy used by the global `JsonStringEnumConverter`,
1246        // matching exactly what serde would emit on the wire.
1247        body.push_str("                if (choice.FinishReason != null)\n");
1248        body.push_str("                {\n");
1249        body.push_str(
1250            "                    lastFinishReason = JsonNamingPolicy.SnakeCaseLower.ConvertName(choice.FinishReason.ToString()!);\n",
1251        );
1252        body.push_str("                }\n");
1253    }
1254    if needs_tool_calls_json || needs_tool_calls_0_function_name {
1255        body.push_str("                var tcs = delta?.ToolCalls;\n");
1256        body.push_str("                if (tcs != null && tcs.Count > 0)\n");
1257        body.push_str("                {\n");
1258        if needs_tool_calls_json {
1259            body.push_str(
1260                "                    toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
1261            );
1262        }
1263        if needs_tool_calls_0_function_name {
1264            body.push_str("                    toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
1265        }
1266        body.push_str("                }\n");
1267    }
1268    body.push_str("            }\n");
1269    if needs_total_tokens {
1270        body.push_str("            if (chunk.Usage != null)\n");
1271        body.push_str("            {\n");
1272        body.push_str("                totalTokens = chunk.Usage.TotalTokens;\n");
1273        body.push_str("            }\n");
1274    }
1275    body.push_str("        }\n");
1276    body.push_str("        streamComplete = true;\n");
1277
1278    // Emit assertions on local aggregator vars.
1279    let mut had_explicit_complete = false;
1280    for assertion in &fixture.assertions {
1281        if assertion.field.as_deref() == Some("stream_complete") {
1282            had_explicit_complete = true;
1283        }
1284        emit_chat_stream_assertion(&mut body, assertion);
1285    }
1286    if !had_explicit_complete {
1287        body.push_str("        Assert.True(streamComplete);\n");
1288    }
1289
1290    body.push_str("    }\n");
1291
1292    for line in body.lines() {
1293        out.push_str("    ");
1294        out.push_str(line);
1295        out.push('\n');
1296    }
1297}
1298
1299/// Map a streaming fixture assertion to an `Assert` call on the local aggregator
1300/// variable produced by `render_chat_stream_test_method`. Pseudo-fields like
1301/// `chunks` / `stream_content` / `stream_complete` resolve to in-method locals.
1302fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
1303    let atype = assertion.assertion_type.as_str();
1304    if atype == "not_error" || atype == "error" {
1305        return;
1306    }
1307    let field = assertion.field.as_deref().unwrap_or("");
1308
1309    enum Kind {
1310        Chunks,
1311        Bool,
1312        Str,
1313        IntTokens,
1314        Json,
1315        Unsupported,
1316    }
1317
1318    let (expr, kind) = match field {
1319        "chunks" => ("chunks", Kind::Chunks),
1320        "stream_content" => ("streamContent.ToString()", Kind::Str),
1321        "stream_complete" => ("streamComplete", Kind::Bool),
1322        "no_chunks_after_done" => ("streamComplete", Kind::Bool),
1323        "finish_reason" => ("lastFinishReason", Kind::Str),
1324        "tool_calls" => ("toolCallsJson", Kind::Json),
1325        "tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
1326        "usage.total_tokens" => ("totalTokens", Kind::IntTokens),
1327        _ => ("", Kind::Unsupported),
1328    };
1329
1330    if matches!(kind, Kind::Unsupported) {
1331        let _ = writeln!(
1332            out,
1333            "        // skipped: streaming assertion on unsupported field '{field}'"
1334        );
1335        return;
1336    }
1337
1338    match (atype, &kind) {
1339        ("count_min", Kind::Chunks) => {
1340            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1341                let _ = writeln!(
1342                    out,
1343                    "        Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
1344                );
1345            }
1346        }
1347        ("count_equals", Kind::Chunks) => {
1348            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1349                let _ = writeln!(out, "        Assert.Equal({n}, chunks.Count);");
1350            }
1351        }
1352        ("equals", Kind::Str) => {
1353            if let Some(val) = &assertion.value {
1354                let cs_val = json_to_csharp(val);
1355                let _ = writeln!(out, "        Assert.Equal({cs_val}, {expr});");
1356            }
1357        }
1358        ("contains", Kind::Str) => {
1359            if let Some(val) = &assertion.value {
1360                let cs_val = json_to_csharp(val);
1361                let _ = writeln!(out, "        Assert.Contains({cs_val}, {expr} ?? string.Empty);");
1362            }
1363        }
1364        ("not_empty", Kind::Str) => {
1365            let _ = writeln!(out, "        Assert.False(string.IsNullOrEmpty({expr}));");
1366        }
1367        ("not_empty", Kind::Json) => {
1368            let _ = writeln!(out, "        Assert.NotNull({expr});");
1369        }
1370        ("is_empty", Kind::Str) => {
1371            let _ = writeln!(out, "        Assert.True(string.IsNullOrEmpty({expr}));");
1372        }
1373        ("is_true", Kind::Bool) => {
1374            let _ = writeln!(out, "        Assert.True({expr});");
1375        }
1376        ("is_false", Kind::Bool) => {
1377            let _ = writeln!(out, "        Assert.False({expr});");
1378        }
1379        ("greater_than_or_equal", Kind::IntTokens) => {
1380            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1381                let _ = writeln!(out, "        Assert.True({expr} >= {n}, \"expected >= {n}\");");
1382            }
1383        }
1384        ("equals", Kind::IntTokens) => {
1385            if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1386                let _ = writeln!(out, "        Assert.Equal((long?){n}, {expr});");
1387            }
1388        }
1389        _ => {
1390            let _ = writeln!(
1391                out,
1392                "        // skipped: streaming assertion '{atype}' on field '{field}' not supported"
1393            );
1394        }
1395    }
1396}
1397
1398/// Build setup lines (e.g. handle creation) and the argument list for the function call.
1399///
1400/// Returns `(setup_lines, args_string)`.
1401#[allow(clippy::too_many_arguments)]
1402fn build_args_and_setup(
1403    input: &serde_json::Value,
1404    args: &[crate::config::ArgMapping],
1405    class_name: &str,
1406    options_type: Option<&str>,
1407    options_via: Option<&str>,
1408    enum_fields: &HashMap<String, String>,
1409    nested_types: &HashMap<String, String>,
1410    fixture: &crate::fixture::Fixture,
1411) -> (Vec<String>, String) {
1412    let fixture_id = &fixture.id;
1413    if args.is_empty() {
1414        return (Vec::new(), String::new());
1415    }
1416
1417    let mut setup_lines: Vec<String> = Vec::new();
1418    let mut parts: Vec<String> = Vec::new();
1419
1420    for arg in args {
1421        if arg.arg_type == "bytes" {
1422            // bytes args must be passed as byte[] in C#.
1423            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1424            let val = input.get(field);
1425            match val {
1426                None | Some(serde_json::Value::Null) if arg.optional => {
1427                    parts.push("null".to_string());
1428                }
1429                None | Some(serde_json::Value::Null) => {
1430                    parts.push("System.Array.Empty<byte>()".to_string());
1431                }
1432                Some(v) => {
1433                    // Classify the value to determine how to interpret it:
1434                    // - File paths (like "pdf/fake.pdf") → File.ReadAllBytes(path)
1435                    // - Inline text → System.Text.Encoding.UTF8.GetBytes()
1436                    // - Base64 → Convert.FromBase64String()
1437                    if let Some(s) = v.as_str() {
1438                        let bytes_code = classify_bytes_value_csharp(s);
1439                        parts.push(bytes_code);
1440                    } else {
1441                        // Literal arrays or other non-string types: use as-is
1442                        let cs_str = json_to_csharp(v);
1443                        parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
1444                    }
1445                }
1446            }
1447            continue;
1448        }
1449
1450        if arg.arg_type == "mock_url" {
1451            if fixture.has_host_root_route() {
1452                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1453                setup_lines.push(format!(
1454                    "var _pfUrl_{name} = Environment.GetEnvironmentVariable(\"{env_key}\");",
1455                    name = arg.name,
1456                ));
1457                setup_lines.push(format!(
1458                    "var {} = !string.IsNullOrEmpty(_pfUrl_{name}) ? _pfUrl_{name} : Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1459                    arg.name,
1460                    name = arg.name,
1461                ));
1462            } else {
1463                setup_lines.push(format!(
1464                    "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1465                    arg.name,
1466                ));
1467            }
1468            parts.push(arg.name.clone());
1469            continue;
1470        }
1471
1472        if arg.arg_type == "handle" {
1473            // Generate a CreateEngine (or equivalent) call and pass the variable.
1474            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
1475            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1476            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1477            if config_value.is_null()
1478                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1479            {
1480                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
1481            } else {
1482                // Sort discriminator fields ("type") to appear first in nested objects so
1483                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
1484                // reading other properties (a requirement as of .NET 8).
1485                let sorted = sort_discriminator_first(config_value.clone());
1486                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1487                let name = &arg.name;
1488                setup_lines.push(format!(
1489                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
1490                    escape_csharp(&json_str),
1491                ));
1492                setup_lines.push(format!(
1493                    "var {} = {class_name}.{constructor_name}({name}Config);",
1494                    arg.name,
1495                    name = name,
1496                ));
1497            }
1498            parts.push(arg.name.clone());
1499            continue;
1500        }
1501
1502        // When field is exactly "input", treat the entire input object as the value.
1503        // This matches the convention used by other language generators (e.g. Go).
1504        let val: Option<&serde_json::Value> = if arg.field == "input" {
1505            Some(input)
1506        } else {
1507            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1508            input.get(field)
1509        };
1510        match val {
1511            None | Some(serde_json::Value::Null) if arg.optional => {
1512                // Optional arg with no fixture value: pass null explicitly since
1513                // C# nullable parameters still require an argument at the call site.
1514                parts.push("null".to_string());
1515                continue;
1516            }
1517            None | Some(serde_json::Value::Null) => {
1518                // Required arg with no fixture value: pass a language-appropriate default.
1519                // For json_object args with a known options_type, use `new OptionsType()`
1520                // so the generated code compiles when the method parameter is non-nullable.
1521                let default_val = match arg.arg_type.as_str() {
1522                    "string" => "\"\"".to_string(),
1523                    "int" | "integer" => "0".to_string(),
1524                    "float" | "number" => "0.0d".to_string(),
1525                    "bool" | "boolean" => "false".to_string(),
1526                    "json_object" => {
1527                        if let Some(opts_type) = options_type {
1528                            format!("new {opts_type}()")
1529                        } else {
1530                            "null".to_string()
1531                        }
1532                    }
1533                    _ => "null".to_string(),
1534                };
1535                parts.push(default_val);
1536            }
1537            Some(v) => {
1538                if arg.arg_type == "json_object" {
1539                    // `options_via = "from_json"`: deserialize the entire value (object,
1540                    // array, or scalar) as the options type. This sidesteps per-field
1541                    // type ambiguity — e.g. `JsonElement?` (untagged unions) or
1542                    // `List<NamedRecord>` whose element type cannot be inferred from
1543                    // JSON shape alone — by delegating to System.Text.Json.
1544                    if options_via == Some("from_json")
1545                        && let Some(opts_type) = options_type
1546                    {
1547                        let sorted = sort_discriminator_first(v.clone());
1548                        let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1549                        let escaped = escape_csharp(&json_str);
1550                        // Use the binding-emitted `<Type>.FromJson(...)` factory so any
1551                        // System.Text.Json deserialization failure is wrapped in
1552                        // `<Crate>Exception`, allowing error fixtures asserting
1553                        // `Assert.ThrowsAny<<Crate>Exception>(...)` to catch the parse
1554                        // failure (e.g. `Unknown FilePurpose value: invalid-purpose`).
1555                        parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
1556                        continue;
1557                    }
1558                    // Array value: generate a typed List<T> based on element_type.
1559                    if let Some(arr) = v.as_array() {
1560                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
1561                        continue;
1562                    }
1563                    // Object value with known type: generate idiomatic C# object initializer.
1564                    if let Some(opts_type) = options_type {
1565                        if let Some(obj) = v.as_object() {
1566                            parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
1567                            continue;
1568                        }
1569                    }
1570                }
1571                parts.push(json_to_csharp(v));
1572            }
1573        }
1574    }
1575
1576    (setup_lines, parts.join(", "))
1577}
1578
1579/// Convert a JSON array to a typed C# `List<T>` expression.
1580///
1581/// Mapping from `ArgMapping::element_type`:
1582/// - `None` or any string type → `List<string>`
1583/// - `"f32"` → `List<float>` with `(float)` casts
1584/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
1585/// - `"BatchBytesItem"` / `"BatchFileItem"` → array of batch item instances
1586fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
1587    match element_type {
1588        Some("BatchBytesItem") => {
1589            let items: Vec<String> = arr
1590                .iter()
1591                .filter_map(|v| v.as_object())
1592                .map(|obj| {
1593                    let content = obj.get("content").and_then(|v| v.as_array());
1594                    let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1595                    let content_code = if let Some(arr) = content {
1596                        let bytes: Vec<String> = arr
1597                            .iter()
1598                            .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1599                            .collect();
1600                        format!("new byte[] {{ {} }}", bytes.join(", "))
1601                    } else {
1602                        "new byte[] { }".to_string()
1603                    };
1604                    format!(
1605                        "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1606                        content_code, mime_type
1607                    )
1608                })
1609                .collect();
1610            format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1611        }
1612        Some("BatchFileItem") => {
1613            let items: Vec<String> = arr
1614                .iter()
1615                .filter_map(|v| v.as_object())
1616                .map(|obj| {
1617                    let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1618                    format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1619                })
1620                .collect();
1621            format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1622        }
1623        Some("f32") => {
1624            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1625            format!("new List<float>() {{ {} }}", items.join(", "))
1626        }
1627        Some("(String, String)") => {
1628            let items: Vec<String> = arr
1629                .iter()
1630                .map(|v| {
1631                    let strs: Vec<String> = v
1632                        .as_array()
1633                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1634                    format!("new List<string>() {{ {} }}", strs.join(", "))
1635                })
1636                .collect();
1637            format!("new List<List<string>>() {{ {} }}", items.join(", "))
1638        }
1639        Some(et)
1640            if et != "f32"
1641                && et != "(String, String)"
1642                && et != "string"
1643                && et != "BatchBytesItem"
1644                && et != "BatchFileItem" =>
1645        {
1646            // Class/record types: deserialize each element from JSON
1647            let items: Vec<String> = arr
1648                .iter()
1649                .map(|v| {
1650                    let json_str = serde_json::to_string(v).unwrap_or_default();
1651                    let escaped = escape_csharp(&json_str);
1652                    format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1653                })
1654                .collect();
1655            format!("new List<{et}>() {{ {} }}", items.join(", "))
1656        }
1657        _ => {
1658            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1659            format!("new List<string>() {{ {} }}", items.join(", "))
1660        }
1661    }
1662}
1663
1664/// Detect if a field path accesses a discriminated union variant in C#.
1665/// Pattern: `metadata.format.<variant_name>.<field_name>`
1666/// Returns: Some((accessor, variant_name, inner_field)) if matched, otherwise None
1667fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1668    let parts: Vec<&str> = field.split('.').collect();
1669    if parts.len() >= 3 && parts.len() <= 4 {
1670        // Check if this is metadata.format.{variant}.{field} pattern
1671        if parts[0] == "metadata" && parts[1] == "format" {
1672            let variant_name = parts[2];
1673            // Known C# discriminated union variants (lowercase in fixture paths)
1674            let known_variants = [
1675                "pdf",
1676                "docx",
1677                "excel",
1678                "email",
1679                "pptx",
1680                "archive",
1681                "image",
1682                "xml",
1683                "text",
1684                "html",
1685                "ocr",
1686                "csv",
1687                "bibtex",
1688                "citation",
1689                "fiction_book",
1690                "dbf",
1691                "jats",
1692                "epub",
1693                "pst",
1694                "code",
1695            ];
1696            if known_variants.contains(&variant_name) {
1697                let variant_pascal = variant_name.to_upper_camel_case();
1698                if parts.len() == 4 {
1699                    let inner_field = parts[3];
1700                    return Some((
1701                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1702                        variant_pascal,
1703                        inner_field.to_string(),
1704                    ));
1705                } else if parts.len() == 3 {
1706                    // Just accessing the variant itself (no inner field)
1707                    return Some((
1708                        format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1709                        variant_pascal,
1710                        String::new(),
1711                    ));
1712                }
1713            }
1714        }
1715    }
1716    None
1717}
1718
1719/// Render an assertion against a discriminated union variant's inner field.
1720/// `variant_var` is the unwrapped union variant (e.g., `variant` from pattern match).
1721/// `inner_field` is the field to access on the variant's Value (e.g., `sheet_count`).
1722fn render_discriminated_union_assertion(
1723    out: &mut String,
1724    assertion: &Assertion,
1725    variant_var: &str,
1726    inner_field: &str,
1727    _result_is_vec: bool,
1728) {
1729    if inner_field.is_empty() {
1730        return; // No field to assert on
1731    }
1732
1733    let field_pascal = inner_field.to_upper_camel_case();
1734    let field_expr = format!("{variant_var}.Value.{field_pascal}");
1735
1736    match assertion.assertion_type.as_str() {
1737        "equals" => {
1738            if let Some(expected) = &assertion.value {
1739                let cs_val = json_to_csharp(expected);
1740                if expected.is_string() {
1741                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr}!.Trim());");
1742                } else if expected.as_bool() == Some(true) {
1743                    let _ = writeln!(out, "            Assert.True({field_expr});");
1744                } else if expected.as_bool() == Some(false) {
1745                    let _ = writeln!(out, "            Assert.False({field_expr});");
1746                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1747                    let _ = writeln!(out, "            Assert.True({field_expr} == {cs_val});");
1748                } else {
1749                    let _ = writeln!(out, "            Assert.Equal({cs_val}, {field_expr});");
1750                }
1751            }
1752        }
1753        "greater_than_or_equal" => {
1754            if let Some(val) = &assertion.value {
1755                let cs_val = json_to_csharp(val);
1756                let _ = writeln!(
1757                    out,
1758                    "            Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1759                );
1760            }
1761        }
1762        "contains_all" => {
1763            if let Some(values) = &assertion.values {
1764                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1765                for val in values {
1766                    let lower_val = val.as_str().map(|s| s.to_lowercase());
1767                    let cs_val = lower_val
1768                        .as_deref()
1769                        .map(|s| format!("\"{}\"", escape_csharp(s)))
1770                        .unwrap_or_else(|| json_to_csharp(val));
1771                    let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1772                }
1773            }
1774        }
1775        "contains" => {
1776            if let Some(expected) = &assertion.value {
1777                let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1778                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1779                let cs_val = lower_expected
1780                    .as_deref()
1781                    .map(|s| format!("\"{}\"", escape_csharp(s)))
1782                    .unwrap_or_else(|| json_to_csharp(expected));
1783                let _ = writeln!(out, "            Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1784            }
1785        }
1786        "not_empty" => {
1787            let _ = writeln!(out, "            Assert.NotEmpty({field_expr});");
1788        }
1789        "is_empty" => {
1790            let _ = writeln!(out, "            Assert.Empty({field_expr});");
1791        }
1792        _ => {
1793            let _ = writeln!(
1794                out,
1795                "            // skipped: assertion type '{}' not yet supported for discriminated union fields",
1796                assertion.assertion_type
1797            );
1798        }
1799    }
1800}
1801
1802#[allow(clippy::too_many_arguments)]
1803fn render_assertion(
1804    out: &mut String,
1805    assertion: &Assertion,
1806    result_var: &str,
1807    class_name: &str,
1808    exception_class: &str,
1809    field_resolver: &FieldResolver,
1810    result_is_simple: bool,
1811    result_is_vec: bool,
1812    result_is_array: bool,
1813    result_is_bytes: bool,
1814    fields_enum: &std::collections::HashSet<String>,
1815) {
1816    // Byte-buffer returns: emit length-based assertions instead of struct-field
1817    // accessors. The result is a `byte[]` and has no named fields like
1818    // `result.Audio` or `result.Content`.
1819    if result_is_bytes {
1820        match assertion.assertion_type.as_str() {
1821            "not_empty" => {
1822                let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
1823                return;
1824            }
1825            "is_empty" => {
1826                let _ = writeln!(out, "        Assert.Empty({result_var});");
1827                return;
1828            }
1829            "count_equals" | "length_equals" => {
1830                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1831                    let _ = writeln!(out, "        Assert.Equal({n}, {result_var}.Length);");
1832                }
1833                return;
1834            }
1835            "count_min" | "length_min" => {
1836                if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1837                    let _ = writeln!(out, "        Assert.True({result_var}.Length >= {n});");
1838                }
1839                return;
1840            }
1841            _ => {
1842                // Other assertion types are not meaningful on raw byte buffers;
1843                // emit a comment so the test still compiles but flags unsupported
1844                // assertion types for fixture authors.
1845                let _ = writeln!(
1846                    out,
1847                    "        // skipped: assertion type '{}' not supported on byte[] result",
1848                    assertion.assertion_type
1849                );
1850                return;
1851            }
1852        }
1853    }
1854    // Handle synthetic / derived fields before the is_valid_for_result check
1855    // so they are never treated as struct property accesses on the result.
1856    if let Some(f) = &assertion.field {
1857        match f.as_str() {
1858            "chunks_have_content" => {
1859                let synthetic_pred =
1860                    format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
1861                let synthetic_pred_type = match assertion.assertion_type.as_str() {
1862                    "is_true" => "is_true",
1863                    "is_false" => "is_false",
1864                    _ => {
1865                        out.push_str(&format!(
1866                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
1867                        ));
1868                        return;
1869                    }
1870                };
1871                let rendered = crate::template_env::render(
1872                    "csharp/assertion.jinja",
1873                    minijinja::context! {
1874                        assertion_type => "synthetic_assertion",
1875                        synthetic_pred => synthetic_pred,
1876                        synthetic_pred_type => synthetic_pred_type,
1877                    },
1878                );
1879                out.push_str(&rendered);
1880                return;
1881            }
1882            "chunks_have_embeddings" => {
1883                let synthetic_pred =
1884                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
1885                let synthetic_pred_type = match assertion.assertion_type.as_str() {
1886                    "is_true" => "is_true",
1887                    "is_false" => "is_false",
1888                    _ => {
1889                        out.push_str(&format!(
1890                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
1891                        ));
1892                        return;
1893                    }
1894                };
1895                let rendered = crate::template_env::render(
1896                    "csharp/assertion.jinja",
1897                    minijinja::context! {
1898                        assertion_type => "synthetic_assertion",
1899                        synthetic_pred => synthetic_pred,
1900                        synthetic_pred_type => synthetic_pred_type,
1901                    },
1902                );
1903                out.push_str(&rendered);
1904                return;
1905            }
1906            // ---- EmbedResponse virtual fields ----
1907            // embed_texts returns List<List<float>> in C# — no wrapper object.
1908            // result_var is the embedding matrix; use it directly.
1909            "embeddings" => {
1910                match assertion.assertion_type.as_str() {
1911                    "count_equals" => {
1912                        if let Some(val) = &assertion.value {
1913                            if let Some(n) = val.as_u64() {
1914                                let rendered = crate::template_env::render(
1915                                    "csharp/assertion.jinja",
1916                                    minijinja::context! {
1917                                        assertion_type => "synthetic_embeddings_count_equals",
1918                                        synthetic_pred => format!("{result_var}.Count"),
1919                                        n => n,
1920                                    },
1921                                );
1922                                out.push_str(&rendered);
1923                            }
1924                        }
1925                    }
1926                    "count_min" => {
1927                        if let Some(val) = &assertion.value {
1928                            if let Some(n) = val.as_u64() {
1929                                let rendered = crate::template_env::render(
1930                                    "csharp/assertion.jinja",
1931                                    minijinja::context! {
1932                                        assertion_type => "synthetic_embeddings_count_min",
1933                                        synthetic_pred => format!("{result_var}.Count"),
1934                                        n => n,
1935                                    },
1936                                );
1937                                out.push_str(&rendered);
1938                            }
1939                        }
1940                    }
1941                    "not_empty" => {
1942                        let rendered = crate::template_env::render(
1943                            "csharp/assertion.jinja",
1944                            minijinja::context! {
1945                                assertion_type => "synthetic_embeddings_not_empty",
1946                                synthetic_pred => result_var.to_string(),
1947                            },
1948                        );
1949                        out.push_str(&rendered);
1950                    }
1951                    "is_empty" => {
1952                        let rendered = crate::template_env::render(
1953                            "csharp/assertion.jinja",
1954                            minijinja::context! {
1955                                assertion_type => "synthetic_embeddings_is_empty",
1956                                synthetic_pred => result_var.to_string(),
1957                            },
1958                        );
1959                        out.push_str(&rendered);
1960                    }
1961                    _ => {
1962                        out.push_str(
1963                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
1964                        );
1965                    }
1966                }
1967                return;
1968            }
1969            "embedding_dimensions" => {
1970                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
1971                match assertion.assertion_type.as_str() {
1972                    "equals" => {
1973                        if let Some(val) = &assertion.value {
1974                            if let Some(n) = val.as_u64() {
1975                                let rendered = crate::template_env::render(
1976                                    "csharp/assertion.jinja",
1977                                    minijinja::context! {
1978                                        assertion_type => "synthetic_embedding_dimensions_equals",
1979                                        synthetic_pred => expr,
1980                                        n => n,
1981                                    },
1982                                );
1983                                out.push_str(&rendered);
1984                            }
1985                        }
1986                    }
1987                    "greater_than" => {
1988                        if let Some(val) = &assertion.value {
1989                            if let Some(n) = val.as_u64() {
1990                                let rendered = crate::template_env::render(
1991                                    "csharp/assertion.jinja",
1992                                    minijinja::context! {
1993                                        assertion_type => "synthetic_embedding_dimensions_greater_than",
1994                                        synthetic_pred => expr,
1995                                        n => n,
1996                                    },
1997                                );
1998                                out.push_str(&rendered);
1999                            }
2000                        }
2001                    }
2002                    _ => {
2003                        out.push_str("        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
2004                    }
2005                }
2006                return;
2007            }
2008            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2009                let synthetic_pred = match f.as_str() {
2010                    "embeddings_valid" => {
2011                        format!("{result_var}.All(e => e.Count > 0)")
2012                    }
2013                    "embeddings_finite" => {
2014                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
2015                    }
2016                    "embeddings_non_zero" => {
2017                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
2018                    }
2019                    "embeddings_normalized" => {
2020                        format!(
2021                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
2022                        )
2023                    }
2024                    _ => unreachable!(),
2025                };
2026                let synthetic_pred_type = match assertion.assertion_type.as_str() {
2027                    "is_true" => "is_true",
2028                    "is_false" => "is_false",
2029                    _ => {
2030                        out.push_str(&format!(
2031                            "        // skipped: unsupported assertion type on synthetic field '{f}'\n"
2032                        ));
2033                        return;
2034                    }
2035                };
2036                let rendered = crate::template_env::render(
2037                    "csharp/assertion.jinja",
2038                    minijinja::context! {
2039                        assertion_type => "synthetic_assertion",
2040                        synthetic_pred => synthetic_pred,
2041                        synthetic_pred_type => synthetic_pred_type,
2042                    },
2043                );
2044                out.push_str(&rendered);
2045                return;
2046            }
2047            // ---- keywords / keywords_count ----
2048            // C# ExtractionResult does not expose extracted_keywords; skip.
2049            "keywords" | "keywords_count" => {
2050                let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
2051                let rendered = crate::template_env::render(
2052                    "csharp/assertion.jinja",
2053                    minijinja::context! {
2054                        skipped_reason => skipped_reason,
2055                    },
2056                );
2057                out.push_str(&rendered);
2058                return;
2059            }
2060            _ => {}
2061        }
2062    }
2063
2064    // Skip assertions on fields that don't exist on the result type.
2065    if let Some(f) = &assertion.field {
2066        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2067            let skipped_reason = format!("field '{f}' not available on result type");
2068            let rendered = crate::template_env::render(
2069                "csharp/assertion.jinja",
2070                minijinja::context! {
2071                    skipped_reason => skipped_reason,
2072                },
2073            );
2074            out.push_str(&rendered);
2075            return;
2076        }
2077    }
2078
2079    // For count assertions on list results with no field specified, use the list directly.
2080    // Otherwise, when the result is a List<T>, index into the first element for field access.
2081    let is_count_assertion = matches!(
2082        assertion.assertion_type.as_str(),
2083        "count_equals" | "count_min" | "count_max"
2084    );
2085    let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
2086    let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
2087
2088    let effective_result_var: String = if result_is_vec && !use_list_directly {
2089        format!("{result_var}[0]")
2090    } else {
2091        result_var.to_string()
2092    };
2093
2094    // Check if this is a discriminated union access (e.g., metadata.format.excel.sheet_count)
2095    let is_discriminated_union = assertion
2096        .field
2097        .as_ref()
2098        .is_some_and(|f| parse_discriminated_union_access(f).is_some());
2099
2100    // For discriminated union assertions, generate pattern-matching wrapper
2101    if is_discriminated_union {
2102        if let Some((_, variant_name, inner_field)) = assertion
2103            .field
2104            .as_ref()
2105            .and_then(|f| parse_discriminated_union_access(f))
2106        {
2107            // Use a unique variable name based on the field hash to avoid shadowing
2108            let mut hasher = std::collections::hash_map::DefaultHasher::new();
2109            inner_field.hash(&mut hasher);
2110            let var_hash = format!("{:x}", hasher.finish());
2111            let variant_var = format!("variant_{}", &var_hash[..8]);
2112            let _ = writeln!(
2113                out,
2114                "        if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
2115                variant_name, &variant_var
2116            );
2117            let _ = writeln!(out, "        {{");
2118            render_discriminated_union_assertion(out, assertion, &variant_var, &inner_field, result_is_vec);
2119            let _ = writeln!(out, "        }}");
2120            let _ = writeln!(out, "        else");
2121            let _ = writeln!(out, "        {{");
2122            let _ = writeln!(
2123                out,
2124                "            Assert.Fail(\"Expected {} format metadata\");",
2125                variant_name.to_lowercase()
2126            );
2127            let _ = writeln!(out, "        }}");
2128            return;
2129        }
2130    }
2131
2132    let field_expr = if result_is_simple {
2133        effective_result_var.clone()
2134    } else {
2135        match &assertion.field {
2136            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
2137            _ => effective_result_var.clone(),
2138        }
2139    };
2140
2141    // Determine if field_expr is a list or complex object that requires JSON serialization
2142    // for string-based assertions (contains, not_contains, etc.). List<T>.ToString() in C#
2143    // returns the type name, not the contents.
2144    let field_needs_json_serialize = if result_is_simple {
2145        // Simple results are scalars, but when they're also arrays (e.g., List<string>),
2146        // JSON-serialize so substring checks see actual content, not the type name.
2147        result_is_array
2148    } else {
2149        match &assertion.field {
2150            Some(f) if !f.is_empty() => field_resolver.is_array(f),
2151            // No field specified — the whole result object; needs serialization when complex.
2152            _ => !result_is_simple,
2153        }
2154    };
2155    // Build the string representation of field_expr for substring-based assertions.
2156    let field_as_str = if field_needs_json_serialize {
2157        format!("JsonSerializer.Serialize({field_expr})")
2158    } else {
2159        format!("{field_expr}.ToString()")
2160    };
2161
2162    // Detect enum-typed fields. C# emits typed enums (e.g. `FinishReason?`) for
2163    // these so the codegen must avoid `.Trim()` (string-only) and instead
2164    // compare via `?.ToString()?.ToLower()` to match snake_case JSON.
2165    let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
2166        let resolved = field_resolver.resolve(f);
2167        fields_enum.contains(f) || fields_enum.contains(resolved)
2168    });
2169
2170    match assertion.assertion_type.as_str() {
2171        "equals" => {
2172            if let Some(expected) = &assertion.value {
2173                // Enum field equality bypasses the template (which would emit `.Trim()`,
2174                // a string-only API). Compare the snake-cased ToString() against the
2175                // expected value to match the wire JSON form (`InProgress` → `in_progress`,
2176                // `ContentFilter` → `content_filter`, etc.). `JsonNamingPolicy.SnakeCaseLower`
2177                // is the same policy used by the global JsonStringEnumConverter, so the
2178                // assertion compares against exactly what serde would emit.
2179                if field_is_enum && expected.is_string() {
2180                    let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
2181                    let _ = writeln!(
2182                        out,
2183                        "        Assert.Equal(\"{}\", {field_expr} == null ? null : JsonNamingPolicy.SnakeCaseLower.ConvertName({field_expr}.ToString()!));",
2184                        escape_csharp(&s_lower)
2185                    );
2186                    return;
2187                }
2188                let cs_val = json_to_csharp(expected);
2189                let is_string_val = expected.is_string();
2190                let is_bool_true = expected.as_bool() == Some(true);
2191                let is_bool_false = expected.as_bool() == Some(false);
2192                let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
2193
2194                let rendered = crate::template_env::render(
2195                    "csharp/assertion.jinja",
2196                    minijinja::context! {
2197                        assertion_type => "equals",
2198                        field_expr => field_expr.clone(),
2199                        cs_val => cs_val,
2200                        is_string_val => is_string_val,
2201                        is_bool_true => is_bool_true,
2202                        is_bool_false => is_bool_false,
2203                        is_integer_val => is_integer_val,
2204                    },
2205                );
2206                out.push_str(&rendered);
2207            }
2208        }
2209        "contains" => {
2210            if let Some(expected) = &assertion.value {
2211                // Lowercase both expected and actual so that enum fields (where .ToString()
2212                // returns the PascalCase C# member name like "Anchor") correctly match
2213                // fixture snake_case values like "anchor".  String fields are unaffected
2214                // because lowercasing both sides preserves substring matches.
2215                // List/complex fields use JsonSerializer.Serialize() since List<T>.ToString()
2216                // returns the type name, not the contents.
2217                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
2218                let cs_val = lower_expected
2219                    .as_deref()
2220                    .map(|s| format!("\"{}\"", escape_csharp(s)))
2221                    .unwrap_or_else(|| json_to_csharp(expected));
2222
2223                let rendered = crate::template_env::render(
2224                    "csharp/assertion.jinja",
2225                    minijinja::context! {
2226                        assertion_type => "contains",
2227                        field_as_str => field_as_str.clone(),
2228                        cs_val => cs_val,
2229                    },
2230                );
2231                out.push_str(&rendered);
2232            }
2233        }
2234        "contains_all" => {
2235            if let Some(values) = &assertion.values {
2236                let values_cs_lower: Vec<String> = values
2237                    .iter()
2238                    .map(|val| {
2239                        let lower_val = val.as_str().map(|s| s.to_lowercase());
2240                        lower_val
2241                            .as_deref()
2242                            .map(|s| format!("\"{}\"", escape_csharp(s)))
2243                            .unwrap_or_else(|| json_to_csharp(val))
2244                    })
2245                    .collect();
2246
2247                let rendered = crate::template_env::render(
2248                    "csharp/assertion.jinja",
2249                    minijinja::context! {
2250                        assertion_type => "contains_all",
2251                        field_as_str => field_as_str.clone(),
2252                        values_cs_lower => values_cs_lower,
2253                    },
2254                );
2255                out.push_str(&rendered);
2256            }
2257        }
2258        "not_contains" => {
2259            if let Some(expected) = &assertion.value {
2260                let cs_val = json_to_csharp(expected);
2261
2262                let rendered = crate::template_env::render(
2263                    "csharp/assertion.jinja",
2264                    minijinja::context! {
2265                        assertion_type => "not_contains",
2266                        field_as_str => field_as_str.clone(),
2267                        cs_val => cs_val,
2268                    },
2269                );
2270                out.push_str(&rendered);
2271            }
2272        }
2273        "not_empty" => {
2274            let rendered = crate::template_env::render(
2275                "csharp/assertion.jinja",
2276                minijinja::context! {
2277                    assertion_type => "not_empty",
2278                    field_expr => field_expr.clone(),
2279                    field_needs_json_serialize => field_needs_json_serialize,
2280                },
2281            );
2282            out.push_str(&rendered);
2283        }
2284        "is_empty" => {
2285            let rendered = crate::template_env::render(
2286                "csharp/assertion.jinja",
2287                minijinja::context! {
2288                    assertion_type => "is_empty",
2289                    field_expr => field_expr.clone(),
2290                    field_needs_json_serialize => field_needs_json_serialize,
2291                },
2292            );
2293            out.push_str(&rendered);
2294        }
2295        "contains_any" => {
2296            if let Some(values) = &assertion.values {
2297                let checks: Vec<String> = values
2298                    .iter()
2299                    .map(|v| {
2300                        let cs_val = json_to_csharp(v);
2301                        format!("{field_as_str}.Contains({cs_val})")
2302                    })
2303                    .collect();
2304                let contains_any_expr = checks.join(" || ");
2305
2306                let rendered = crate::template_env::render(
2307                    "csharp/assertion.jinja",
2308                    minijinja::context! {
2309                        assertion_type => "contains_any",
2310                        contains_any_expr => contains_any_expr,
2311                    },
2312                );
2313                out.push_str(&rendered);
2314            }
2315        }
2316        "greater_than" => {
2317            if let Some(val) = &assertion.value {
2318                let cs_val = json_to_csharp(val);
2319
2320                let rendered = crate::template_env::render(
2321                    "csharp/assertion.jinja",
2322                    minijinja::context! {
2323                        assertion_type => "greater_than",
2324                        field_expr => field_expr.clone(),
2325                        cs_val => cs_val,
2326                    },
2327                );
2328                out.push_str(&rendered);
2329            }
2330        }
2331        "less_than" => {
2332            if let Some(val) = &assertion.value {
2333                let cs_val = json_to_csharp(val);
2334
2335                let rendered = crate::template_env::render(
2336                    "csharp/assertion.jinja",
2337                    minijinja::context! {
2338                        assertion_type => "less_than",
2339                        field_expr => field_expr.clone(),
2340                        cs_val => cs_val,
2341                    },
2342                );
2343                out.push_str(&rendered);
2344            }
2345        }
2346        "greater_than_or_equal" => {
2347            if let Some(val) = &assertion.value {
2348                let cs_val = json_to_csharp(val);
2349
2350                let rendered = crate::template_env::render(
2351                    "csharp/assertion.jinja",
2352                    minijinja::context! {
2353                        assertion_type => "greater_than_or_equal",
2354                        field_expr => field_expr.clone(),
2355                        cs_val => cs_val,
2356                    },
2357                );
2358                out.push_str(&rendered);
2359            }
2360        }
2361        "less_than_or_equal" => {
2362            if let Some(val) = &assertion.value {
2363                let cs_val = json_to_csharp(val);
2364
2365                let rendered = crate::template_env::render(
2366                    "csharp/assertion.jinja",
2367                    minijinja::context! {
2368                        assertion_type => "less_than_or_equal",
2369                        field_expr => field_expr.clone(),
2370                        cs_val => cs_val,
2371                    },
2372                );
2373                out.push_str(&rendered);
2374            }
2375        }
2376        "starts_with" => {
2377            if let Some(expected) = &assertion.value {
2378                let cs_val = json_to_csharp(expected);
2379
2380                let rendered = crate::template_env::render(
2381                    "csharp/assertion.jinja",
2382                    minijinja::context! {
2383                        assertion_type => "starts_with",
2384                        field_expr => field_expr.clone(),
2385                        cs_val => cs_val,
2386                    },
2387                );
2388                out.push_str(&rendered);
2389            }
2390        }
2391        "ends_with" => {
2392            if let Some(expected) = &assertion.value {
2393                let cs_val = json_to_csharp(expected);
2394
2395                let rendered = crate::template_env::render(
2396                    "csharp/assertion.jinja",
2397                    minijinja::context! {
2398                        assertion_type => "ends_with",
2399                        field_expr => field_expr.clone(),
2400                        cs_val => cs_val,
2401                    },
2402                );
2403                out.push_str(&rendered);
2404            }
2405        }
2406        "min_length" => {
2407            if let Some(val) = &assertion.value {
2408                if let Some(n) = val.as_u64() {
2409                    let rendered = crate::template_env::render(
2410                        "csharp/assertion.jinja",
2411                        minijinja::context! {
2412                            assertion_type => "min_length",
2413                            field_expr => field_expr.clone(),
2414                            n => n,
2415                        },
2416                    );
2417                    out.push_str(&rendered);
2418                }
2419            }
2420        }
2421        "max_length" => {
2422            if let Some(val) = &assertion.value {
2423                if let Some(n) = val.as_u64() {
2424                    let rendered = crate::template_env::render(
2425                        "csharp/assertion.jinja",
2426                        minijinja::context! {
2427                            assertion_type => "max_length",
2428                            field_expr => field_expr.clone(),
2429                            n => n,
2430                        },
2431                    );
2432                    out.push_str(&rendered);
2433                }
2434            }
2435        }
2436        "count_min" => {
2437            if let Some(val) = &assertion.value {
2438                if let Some(n) = val.as_u64() {
2439                    let rendered = crate::template_env::render(
2440                        "csharp/assertion.jinja",
2441                        minijinja::context! {
2442                            assertion_type => "count_min",
2443                            field_expr => field_expr.clone(),
2444                            n => n,
2445                        },
2446                    );
2447                    out.push_str(&rendered);
2448                }
2449            }
2450        }
2451        "count_equals" => {
2452            if let Some(val) = &assertion.value {
2453                if let Some(n) = val.as_u64() {
2454                    let rendered = crate::template_env::render(
2455                        "csharp/assertion.jinja",
2456                        minijinja::context! {
2457                            assertion_type => "count_equals",
2458                            field_expr => field_expr.clone(),
2459                            n => n,
2460                        },
2461                    );
2462                    out.push_str(&rendered);
2463                }
2464            }
2465        }
2466        "is_true" => {
2467            let rendered = crate::template_env::render(
2468                "csharp/assertion.jinja",
2469                minijinja::context! {
2470                    assertion_type => "is_true",
2471                    field_expr => field_expr.clone(),
2472                },
2473            );
2474            out.push_str(&rendered);
2475        }
2476        "is_false" => {
2477            let rendered = crate::template_env::render(
2478                "csharp/assertion.jinja",
2479                minijinja::context! {
2480                    assertion_type => "is_false",
2481                    field_expr => field_expr.clone(),
2482                },
2483            );
2484            out.push_str(&rendered);
2485        }
2486        "not_error" => {
2487            // Already handled by the call succeeding without exception.
2488            let rendered = crate::template_env::render(
2489                "csharp/assertion.jinja",
2490                minijinja::context! {
2491                    assertion_type => "not_error",
2492                },
2493            );
2494            out.push_str(&rendered);
2495        }
2496        "error" => {
2497            // Handled at the test method level.
2498            let rendered = crate::template_env::render(
2499                "csharp/assertion.jinja",
2500                minijinja::context! {
2501                    assertion_type => "error",
2502                },
2503            );
2504            out.push_str(&rendered);
2505        }
2506        "method_result" => {
2507            if let Some(method_name) = &assertion.method {
2508                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2509                let check = assertion.check.as_deref().unwrap_or("is_true");
2510
2511                match check {
2512                    "equals" => {
2513                        if let Some(val) = &assertion.value {
2514                            let is_check_bool_true = val.as_bool() == Some(true);
2515                            let is_check_bool_false = val.as_bool() == Some(false);
2516                            let cs_check_val = json_to_csharp(val);
2517
2518                            let rendered = crate::template_env::render(
2519                                "csharp/assertion.jinja",
2520                                minijinja::context! {
2521                                    assertion_type => "method_result",
2522                                    check => "equals",
2523                                    call_expr => call_expr.clone(),
2524                                    is_check_bool_true => is_check_bool_true,
2525                                    is_check_bool_false => is_check_bool_false,
2526                                    cs_check_val => cs_check_val,
2527                                },
2528                            );
2529                            out.push_str(&rendered);
2530                        }
2531                    }
2532                    "is_true" => {
2533                        let rendered = crate::template_env::render(
2534                            "csharp/assertion.jinja",
2535                            minijinja::context! {
2536                                assertion_type => "method_result",
2537                                check => "is_true",
2538                                call_expr => call_expr.clone(),
2539                            },
2540                        );
2541                        out.push_str(&rendered);
2542                    }
2543                    "is_false" => {
2544                        let rendered = crate::template_env::render(
2545                            "csharp/assertion.jinja",
2546                            minijinja::context! {
2547                                assertion_type => "method_result",
2548                                check => "is_false",
2549                                call_expr => call_expr.clone(),
2550                            },
2551                        );
2552                        out.push_str(&rendered);
2553                    }
2554                    "greater_than_or_equal" => {
2555                        if let Some(val) = &assertion.value {
2556                            let check_n = val.as_u64().unwrap_or(0);
2557
2558                            let rendered = crate::template_env::render(
2559                                "csharp/assertion.jinja",
2560                                minijinja::context! {
2561                                    assertion_type => "method_result",
2562                                    check => "greater_than_or_equal",
2563                                    call_expr => call_expr.clone(),
2564                                    check_n => check_n,
2565                                },
2566                            );
2567                            out.push_str(&rendered);
2568                        }
2569                    }
2570                    "count_min" => {
2571                        if let Some(val) = &assertion.value {
2572                            let check_n = val.as_u64().unwrap_or(0);
2573
2574                            let rendered = crate::template_env::render(
2575                                "csharp/assertion.jinja",
2576                                minijinja::context! {
2577                                    assertion_type => "method_result",
2578                                    check => "count_min",
2579                                    call_expr => call_expr.clone(),
2580                                    check_n => check_n,
2581                                },
2582                            );
2583                            out.push_str(&rendered);
2584                        }
2585                    }
2586                    "is_error" => {
2587                        let rendered = crate::template_env::render(
2588                            "csharp/assertion.jinja",
2589                            minijinja::context! {
2590                                assertion_type => "method_result",
2591                                check => "is_error",
2592                                call_expr => call_expr.clone(),
2593                                exception_class => exception_class,
2594                            },
2595                        );
2596                        out.push_str(&rendered);
2597                    }
2598                    "contains" => {
2599                        if let Some(val) = &assertion.value {
2600                            let cs_check_val = json_to_csharp(val);
2601
2602                            let rendered = crate::template_env::render(
2603                                "csharp/assertion.jinja",
2604                                minijinja::context! {
2605                                    assertion_type => "method_result",
2606                                    check => "contains",
2607                                    call_expr => call_expr.clone(),
2608                                    cs_check_val => cs_check_val,
2609                                },
2610                            );
2611                            out.push_str(&rendered);
2612                        }
2613                    }
2614                    other_check => {
2615                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2616                    }
2617                }
2618            } else {
2619                panic!("C# e2e generator: method_result assertion missing 'method' field");
2620            }
2621        }
2622        "matches_regex" => {
2623            if let Some(expected) = &assertion.value {
2624                let cs_val = json_to_csharp(expected);
2625
2626                let rendered = crate::template_env::render(
2627                    "csharp/assertion.jinja",
2628                    minijinja::context! {
2629                        assertion_type => "matches_regex",
2630                        field_expr => field_expr.clone(),
2631                        cs_val => cs_val,
2632                    },
2633                );
2634                out.push_str(&rendered);
2635            }
2636        }
2637        other => {
2638            panic!("C# e2e generator: unsupported assertion type: {other}");
2639        }
2640    }
2641}
2642
2643/// Recursively sort JSON objects so that any key named `"type"` appears first.
2644///
2645/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
2646/// the first property when deserializing polymorphic types. Fixture config values
2647/// serialised via serde_json preserve insertion/alphabetical order, which may put
2648/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
2649fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
2650    match value {
2651        serde_json::Value::Object(map) => {
2652            let mut sorted = serde_json::Map::with_capacity(map.len());
2653            // Insert "type" first if present.
2654            if let Some(type_val) = map.get("type") {
2655                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
2656            }
2657            for (k, v) in map {
2658                if k != "type" {
2659                    sorted.insert(k, sort_discriminator_first(v));
2660                }
2661            }
2662            serde_json::Value::Object(sorted)
2663        }
2664        serde_json::Value::Array(arr) => {
2665            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
2666        }
2667        other => other,
2668    }
2669}
2670
2671/// Convert a `serde_json::Value` to a C# literal string.
2672fn json_to_csharp(value: &serde_json::Value) -> String {
2673    match value {
2674        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
2675        serde_json::Value::Bool(true) => "true".to_string(),
2676        serde_json::Value::Bool(false) => "false".to_string(),
2677        serde_json::Value::Number(n) => {
2678            if n.is_f64() {
2679                format!("{}d", n)
2680            } else {
2681                n.to_string()
2682            }
2683        }
2684        serde_json::Value::Null => "null".to_string(),
2685        serde_json::Value::Array(arr) => {
2686            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2687            format!("new[] {{ {} }}", items.join(", "))
2688        }
2689        serde_json::Value::Object(_) => {
2690            let json_str = serde_json::to_string(value).unwrap_or_default();
2691            format!("\"{}\"", escape_csharp(&json_str))
2692        }
2693    }
2694}
2695
2696/// Build default nested type mappings for C# extraction config types.
2697///
2698/// Maps known Kreuzberg/Kreuzcrawl config field names (in snake_case) to their
2699/// C# record type names (in PascalCase). These defaults allow e2e codegen to
2700/// automatically deserialize nested config objects without requiring explicit
2701/// configuration in alef.toml. User-provided overrides take precedence.
2702fn default_csharp_nested_types() -> HashMap<String, String> {
2703    [
2704        ("chunking", "ChunkingConfig"),
2705        ("ocr", "OcrConfig"),
2706        ("images", "ImageExtractionConfig"),
2707        ("html_output", "HtmlOutputConfig"),
2708        ("language_detection", "LanguageDetectionConfig"),
2709        ("postprocessor", "PostProcessorConfig"),
2710        ("acceleration", "AccelerationConfig"),
2711        ("email", "EmailConfig"),
2712        ("pages", "PageConfig"),
2713        ("pdf_options", "PdfConfig"),
2714        ("layout", "LayoutDetectionConfig"),
2715        ("tree_sitter", "TreeSitterConfig"),
2716        ("structured_extraction", "StructuredExtractionConfig"),
2717        ("content_filter", "ContentFilterConfig"),
2718        ("token_reduction", "TokenReductionOptions"),
2719        ("security_limits", "SecurityLimits"),
2720        ("format", "FormatMetadata"),
2721    ]
2722    .iter()
2723    .map(|(k, v)| (k.to_string(), v.to_string()))
2724    .collect()
2725}
2726
2727/// Emit a C# object initializer for a JSON options object.
2728///
2729/// - camelCase fixture keys → PascalCase C# property names
2730/// - Enum fields (from `enum_fields`) → `EnumType.Member`
2731/// - Nested objects with known type (from `nested_types`) → `JsonSerializer.Deserialize<T>(...)`
2732/// - Arrays → `new List<string> { ... }`
2733/// - Primitives → C# literals via `json_to_csharp`
2734fn csharp_object_initializer(
2735    obj: &serde_json::Map<String, serde_json::Value>,
2736    type_name: &str,
2737    enum_fields: &HashMap<String, String>,
2738    nested_types: &HashMap<String, String>,
2739) -> String {
2740    if obj.is_empty() {
2741        return format!("new {type_name}()");
2742    }
2743
2744    // Snake_case fixture keys for fields that are real C# enums in the binding.
2745    // The fixture string value (e.g. "markdown") maps to `EnumType.Member` (e.g. `OutputFormat.Markdown`).
2746    static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
2747
2748    let props: Vec<String> = obj
2749        .iter()
2750        .map(|(key, val)| {
2751            let pascal_key = key.to_upper_camel_case();
2752            let implicit_enum_type = IMPLICIT_ENUM_FIELDS
2753                .iter()
2754                .find(|(k, _)| *k == key.as_str())
2755                .map(|(_, t)| *t);
2756            let cs_val =
2757                if let Some(enum_type) = enum_fields.get(key.as_str()).map(String::as_str).or(implicit_enum_type) {
2758                    // Enum: EnumType.Member
2759                    if val.is_null() {
2760                        "null".to_string()
2761                    } else {
2762                        let member = val
2763                            .as_str()
2764                            .map(|s| s.to_upper_camel_case())
2765                            .unwrap_or_else(|| "null".to_string());
2766                        format!("{enum_type}.{member}")
2767                    }
2768                } else if let Some(nested_type) = nested_types.get(key.as_str()) {
2769                    // Nested object: JSON deserialization (keys are typically single-word, matching JsonPropertyName)
2770                    let normalized = normalize_csharp_enum_values(val, enum_fields);
2771                    let json_str = serde_json::to_string(&normalized).unwrap_or_default();
2772                    format!(
2773                        "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
2774                        escape_csharp(&json_str)
2775                    )
2776                } else if let Some(arr) = val.as_array() {
2777                    // Array: List<string>
2778                    let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2779                    format!("new List<string> {{ {} }}", items.join(", "))
2780                } else {
2781                    json_to_csharp(val)
2782                };
2783            format!("{pascal_key} = {cs_val}")
2784        })
2785        .collect();
2786    format!("new {} {{ {} }}", type_name, props.join(", "))
2787}
2788
2789/// Convert enum values in a JSON object to lowercase to match C# [JsonPropertyName] attributes.
2790/// The JSON deserialization uses JsonPropertyName("lowercase_value"), so fixture enum values
2791/// (typically PascalCase like "Tildes") must be converted to lowercase ("tildes") for correct
2792/// deserialization with JsonStringEnumConverter.
2793fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
2794    match value {
2795        serde_json::Value::Object(map) => {
2796            let mut result = map.clone();
2797            for (key, val) in result.iter_mut() {
2798                if enum_fields.contains_key(key) {
2799                    // This is an enum field; convert the string value to lowercase.
2800                    if let Some(s) = val.as_str() {
2801                        *val = serde_json::Value::String(s.to_lowercase());
2802                    }
2803                }
2804            }
2805            serde_json::Value::Object(result)
2806        }
2807        other => other.clone(),
2808    }
2809}
2810
2811// ---------------------------------------------------------------------------
2812// Visitor generation
2813// ---------------------------------------------------------------------------
2814
2815/// Build a C# visitor: add an instantiation line to `setup_lines` and push
2816/// a private nested class declaration to `class_decls` (emitted at class scope,
2817/// outside any method body — C# does not allow local class declarations inside
2818/// methods).  Each fixture gets a unique class name derived from its ID to avoid
2819/// duplicate-name compile errors when multiple visitor fixtures exist per file.
2820/// Returns the visitor variable name for use as a call argument.
2821fn build_csharp_visitor(
2822    setup_lines: &mut Vec<String>,
2823    class_decls: &mut Vec<String>,
2824    fixture_id: &str,
2825    visitor_spec: &crate::fixture::VisitorSpec,
2826) -> String {
2827    use heck::ToUpperCamelCase;
2828    let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
2829    let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
2830
2831    setup_lines.push(format!("var {var_name} = new {class_name}();"));
2832
2833    // Build the class declaration string (indented for nesting inside the test class).
2834    let mut decl = String::new();
2835    decl.push_str(&format!("    private sealed class {class_name} : IHtmlVisitor\n"));
2836    decl.push_str("    {\n");
2837
2838    // List of all visitor methods that must be implemented by IHtmlVisitor.
2839    let all_methods = [
2840        "visit_element_start",
2841        "visit_element_end",
2842        "visit_text",
2843        "visit_link",
2844        "visit_image",
2845        "visit_heading",
2846        "visit_code_block",
2847        "visit_code_inline",
2848        "visit_list_item",
2849        "visit_list_start",
2850        "visit_list_end",
2851        "visit_table_start",
2852        "visit_table_row",
2853        "visit_table_end",
2854        "visit_blockquote",
2855        "visit_strong",
2856        "visit_emphasis",
2857        "visit_strikethrough",
2858        "visit_underline",
2859        "visit_subscript",
2860        "visit_superscript",
2861        "visit_mark",
2862        "visit_line_break",
2863        "visit_horizontal_rule",
2864        "visit_custom_element",
2865        "visit_definition_list_start",
2866        "visit_definition_term",
2867        "visit_definition_description",
2868        "visit_definition_list_end",
2869        "visit_form",
2870        "visit_input",
2871        "visit_button",
2872        "visit_audio",
2873        "visit_video",
2874        "visit_iframe",
2875        "visit_details",
2876        "visit_summary",
2877        "visit_figure_start",
2878        "visit_figcaption",
2879        "visit_figure_end",
2880    ];
2881
2882    // Emit all methods: use fixture action if specified, otherwise default to Continue.
2883    for method_name in &all_methods {
2884        if let Some(action) = visitor_spec.callbacks.get(*method_name) {
2885            emit_csharp_visitor_method(&mut decl, method_name, action);
2886        } else {
2887            // Default: Continue for methods not in the fixture
2888            emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
2889        }
2890    }
2891
2892    decl.push_str("    }\n");
2893    class_decls.push(decl);
2894
2895    var_name
2896}
2897
2898/// Emit a C# visitor method into a class declaration string.
2899fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
2900    let camel_method = method_to_camel(method_name);
2901    let params = match method_name {
2902        "visit_link" => "NodeContext ctx, string href, string text, string title",
2903        "visit_image" => "NodeContext ctx, string src, string alt, string title",
2904        "visit_heading" => "NodeContext ctx, uint level, string text, string id",
2905        "visit_code_block" => "NodeContext ctx, string lang, string code",
2906        "visit_code_inline"
2907        | "visit_strong"
2908        | "visit_emphasis"
2909        | "visit_strikethrough"
2910        | "visit_underline"
2911        | "visit_subscript"
2912        | "visit_superscript"
2913        | "visit_mark"
2914        | "visit_button"
2915        | "visit_summary"
2916        | "visit_figcaption"
2917        | "visit_definition_term"
2918        | "visit_definition_description" => "NodeContext ctx, string text",
2919        "visit_text" => "NodeContext ctx, string text",
2920        "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
2921        "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
2922        "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
2923        "visit_custom_element" => "NodeContext ctx, string tagName, string html",
2924        "visit_form" => "NodeContext ctx, string actionUrl, string method",
2925        "visit_input" => "NodeContext ctx, string inputType, string name, string value",
2926        "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
2927        "visit_details" => "NodeContext ctx, bool isOpen",
2928        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2929            "NodeContext ctx, string output"
2930        }
2931        "visit_list_start" => "NodeContext ctx, bool ordered",
2932        "visit_list_end" => "NodeContext ctx, bool ordered, string output",
2933        "visit_element_start"
2934        | "visit_table_start"
2935        | "visit_definition_list_start"
2936        | "visit_figure_start"
2937        | "visit_line_break"
2938        | "visit_horizontal_rule" => "NodeContext ctx",
2939        _ => "NodeContext ctx",
2940    };
2941
2942    let (action_type, action_value) = match action {
2943        CallbackAction::Skip => ("skip", String::new()),
2944        CallbackAction::Continue => ("continue", String::new()),
2945        CallbackAction::PreserveHtml => ("preserve_html", String::new()),
2946        CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
2947        CallbackAction::CustomTemplate { template } => {
2948            let camel = snake_case_template_to_camel(template);
2949            ("custom_template", escape_csharp(&camel))
2950        }
2951    };
2952
2953    let rendered = crate::template_env::render(
2954        "csharp/visitor_method.jinja",
2955        minijinja::context! {
2956            camel_method => camel_method,
2957            params => params,
2958            action_type => action_type,
2959            action_value => action_value,
2960        },
2961    );
2962    let _ = write!(decl, "{}", rendered);
2963}
2964
2965/// Convert snake_case method names to C# PascalCase.
2966fn method_to_camel(snake: &str) -> String {
2967    use heck::ToUpperCamelCase;
2968    snake.to_upper_camel_case()
2969}
2970
2971/// Rewrite `{snake_case}` placeholders in a custom template to `{camelCase}` so
2972/// they match C# parameter names (which alef emits in camelCase).
2973fn snake_case_template_to_camel(template: &str) -> String {
2974    use heck::ToLowerCamelCase;
2975    let mut out = String::with_capacity(template.len());
2976    let mut chars = template.chars().peekable();
2977    while let Some(c) = chars.next() {
2978        if c == '{' {
2979            let mut name = String::new();
2980            while let Some(&nc) = chars.peek() {
2981                if nc == '}' {
2982                    chars.next();
2983                    break;
2984                }
2985                name.push(nc);
2986                chars.next();
2987            }
2988            out.push('{');
2989            out.push_str(&name.to_lower_camel_case());
2990            out.push('}');
2991        } else {
2992            out.push(c);
2993        }
2994    }
2995    out
2996}
2997
2998/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
2999///
3000/// Maps well-known method names to the appropriate C# static helper calls on the
3001/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
3002fn build_csharp_method_call(
3003    result_var: &str,
3004    method_name: &str,
3005    args: Option<&serde_json::Value>,
3006    class_name: &str,
3007) -> String {
3008    match method_name {
3009        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
3010        "root_node_type" => format!("{result_var}.RootNode.Kind"),
3011        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
3012        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
3013        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
3014        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
3015        "contains_node_type" => {
3016            let node_type = args
3017                .and_then(|a| a.get("node_type"))
3018                .and_then(|v| v.as_str())
3019                .unwrap_or("");
3020            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
3021        }
3022        "find_nodes_by_type" => {
3023            let node_type = args
3024                .and_then(|a| a.get("node_type"))
3025                .and_then(|v| v.as_str())
3026                .unwrap_or("");
3027            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
3028        }
3029        "run_query" => {
3030            let query_source = args
3031                .and_then(|a| a.get("query_source"))
3032                .and_then(|v| v.as_str())
3033                .unwrap_or("");
3034            let language = args
3035                .and_then(|a| a.get("language"))
3036                .and_then(|v| v.as_str())
3037                .unwrap_or("");
3038            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
3039        }
3040        _ => {
3041            use heck::ToUpperCamelCase;
3042            let pascal = method_name.to_upper_camel_case();
3043            format!("{result_var}.{pascal}()")
3044        }
3045    }
3046}
3047
3048fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
3049    // HTTP fixtures are handled separately — not our concern here.
3050    if fixture.is_http_test() {
3051        return false;
3052    }
3053    // Use resolve_call_for_fixture to support auto-routing via select_when.
3054    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
3055    let cs_override = call_config
3056        .overrides
3057        .get("csharp")
3058        .or_else(|| e2e_config.call.overrides.get("csharp"));
3059    // When a client_factory is configured the fixture is callable via the client pattern.
3060    if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
3061        return true;
3062    }
3063    // C# binding provides a default class name (e.g., KreuzcrawlLib) if not overridden,
3064    // so any function name makes a callable available.
3065    cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
3066}
3067
3068/// Classify a fixture string value that maps to a `bytes` argument.
3069/// Determines whether to treat it as a file path, inline text, or base64-encoded data.
3070fn classify_bytes_value_csharp(s: &str) -> String {
3071    // File paths: start with alphanumeric/underscore, contain "/" with extension
3072    // e.g., "pdf/fake.pdf", "images/test.png"
3073    if let Some(first) = s.chars().next() {
3074        if first.is_ascii_alphanumeric() || first == '_' {
3075            if let Some(slash_pos) = s.find('/') {
3076                if slash_pos > 0 {
3077                    let after_slash = &s[slash_pos + 1..];
3078                    if after_slash.contains('.') && !after_slash.is_empty() {
3079                        // File path: use File.ReadAllBytes(path)
3080                        return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
3081                    }
3082                }
3083            }
3084        }
3085    }
3086
3087    // Inline text: starts with markup or contains spaces
3088    // e.g., "<html>...", "{...}", "[...]", "text with spaces"
3089    if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
3090        // Inline text: use System.Text.Encoding.UTF8.GetBytes()
3091        return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
3092    }
3093
3094    // Base64: base64-like pattern (uppercase/lowercase letters, digits, +, /, =)
3095    // e.g., "/9j/4AAQ", "SGVsbG8gV29ybGQ="
3096    // Use Convert.FromBase64String()
3097    format!("System.Convert.FromBase64String(\"{}\")", s)
3098}
3099
3100#[cfg(test)]
3101mod tests {
3102    use crate::config::{CallConfig, E2eConfig, SelectWhen};
3103    use crate::fixture::Fixture;
3104    use std::collections::HashMap;
3105
3106    fn make_fixture_with_input(id: &str, input: serde_json::Value) -> Fixture {
3107        Fixture {
3108            id: id.to_string(),
3109            category: None,
3110            description: "test fixture".to_string(),
3111            tags: vec![],
3112            skip: None,
3113            env: None,
3114            call: None,
3115            input,
3116            mock_response: None,
3117            source: String::new(),
3118            http: None,
3119            assertions: vec![],
3120            visitor: None,
3121        }
3122    }
3123
3124    /// Test that resolve_call_for_fixture correctly routes to batch_scrape
3125    /// when input has batch_urls and select_when condition matches.
3126    #[test]
3127    fn test_csharp_select_when_routes_to_batch_scrape() {
3128        let mut calls = HashMap::new();
3129        calls.insert(
3130            "batch_scrape".to_string(),
3131            CallConfig {
3132                function: "BatchScrape".to_string(),
3133                module: "KreuzBrowser".to_string(),
3134                select_when: Some(SelectWhen::InputHas("batch_urls".to_string())),
3135                ..CallConfig::default()
3136            },
3137        );
3138
3139        let e2e_config = E2eConfig {
3140            call: CallConfig {
3141                function: "Scrape".to_string(),
3142                module: "KreuzBrowser".to_string(),
3143                ..CallConfig::default()
3144            },
3145            calls,
3146            ..E2eConfig::default()
3147        };
3148
3149        // Fixture with batch_urls but no explicit call field should route to batch_scrape
3150        let fixture = make_fixture_with_input("batch_empty_urls", serde_json::json!({ "batch_urls": [] }));
3151
3152        let resolved_call = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
3153        assert_eq!(resolved_call.function, "BatchScrape");
3154
3155        // Fixture without batch_urls should fall back to default Scrape
3156        let fixture_no_batch =
3157            make_fixture_with_input("simple_scrape", serde_json::json!({ "url": "https://example.com" }));
3158        let resolved_default =
3159            e2e_config.resolve_call_for_fixture(fixture_no_batch.call.as_deref(), &fixture_no_batch.input);
3160        assert_eq!(resolved_default.function, "Scrape");
3161    }
3162}