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