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