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};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21
22/// C# e2e code generator.
23pub struct CSharpCodegen;
24
25impl E2eCodegen for CSharpCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let function_name = overrides
41            .and_then(|o| o.function.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.function.to_upper_camel_case());
44        let class_name = overrides
45            .and_then(|o| o.class.as_ref())
46            .cloned()
47            .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
48        // The exception class is always {CrateName}Exception, generated by the C# backend.
49        let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
50        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
51            if call.module.is_empty() {
52                "Kreuzberg".to_string()
53            } else {
54                call.module.to_upper_camel_case()
55            }
56        });
57        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
58        let result_var = &call.result_var;
59        let is_async = call.r#async;
60
61        // Resolve package config.
62        let cs_pkg = e2e_config.resolve_package("csharp");
63        let pkg_name = cs_pkg
64            .as_ref()
65            .and_then(|p| p.name.as_ref())
66            .cloned()
67            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
68        // The project reference path uses the crate name (with hyphens) for the directory
69        // and the PascalCase name for the .csproj file.
70        let pkg_path = cs_pkg
71            .as_ref()
72            .and_then(|p| p.path.as_ref())
73            .cloned()
74            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
75        let pkg_version = cs_pkg
76            .as_ref()
77            .and_then(|p| p.version.as_ref())
78            .cloned()
79            .unwrap_or_else(|| "0.1.0".to_string());
80
81        // Generate the .csproj using a unique name derived from the package name so
82        // it does not conflict with any hand-written project files in the same directory.
83        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
84        files.push(GeneratedFile {
85            path: output_base.join(&csproj_name),
86            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
87            generated_header: false,
88        });
89
90        // Generate test files per category.
91        let tests_base = output_base.join("tests");
92        let field_resolver = FieldResolver::new(
93            &e2e_config.fields,
94            &e2e_config.fields_optional,
95            &e2e_config.result_fields,
96            &e2e_config.fields_array,
97        );
98
99        // Resolve enum_fields from C# override config.
100        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
101        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
102
103        for group in groups {
104            let active: Vec<&Fixture> = group
105                .fixtures
106                .iter()
107                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
108                .collect();
109
110            if active.is_empty() {
111                continue;
112            }
113
114            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
115            let filename = format!("{test_class}.cs");
116            let content = render_test_file(
117                &group.category,
118                &active,
119                &namespace,
120                &class_name,
121                &function_name,
122                &exception_class,
123                result_var,
124                &test_class,
125                &e2e_config.call.args,
126                &field_resolver,
127                result_is_simple,
128                is_async,
129                e2e_config,
130                enum_fields,
131            );
132            files.push(GeneratedFile {
133                path: tests_base.join(filename),
134                content,
135                generated_header: true,
136            });
137        }
138
139        Ok(files)
140    }
141
142    fn language_name(&self) -> &'static str {
143        "csharp"
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Rendering
149// ---------------------------------------------------------------------------
150
151fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
152    let pkg_ref = match dep_mode {
153        crate::config::DependencyMode::Registry => {
154            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
155        }
156        crate::config::DependencyMode::Local => {
157            format!("    <ProjectReference Include=\"{pkg_path}\" />")
158        }
159    };
160    format!(
161        r#"<Project Sdk="Microsoft.NET.Sdk">
162  <PropertyGroup>
163    <TargetFramework>net10.0</TargetFramework>
164    <Nullable>enable</Nullable>
165    <ImplicitUsings>enable</ImplicitUsings>
166    <IsPackable>false</IsPackable>
167    <IsTestProject>true</IsTestProject>
168  </PropertyGroup>
169
170  <ItemGroup>
171    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
172    <PackageReference Include="xunit" Version="{xunit}" />
173    <PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
174  </ItemGroup>
175
176  <ItemGroup>
177{pkg_ref}
178  </ItemGroup>
179</Project>
180"#,
181        ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
182        xunit = tv::nuget::XUNIT,
183        xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
184    )
185}
186
187#[allow(clippy::too_many_arguments)]
188fn render_test_file(
189    category: &str,
190    fixtures: &[&Fixture],
191    namespace: &str,
192    class_name: &str,
193    function_name: &str,
194    exception_class: &str,
195    result_var: &str,
196    test_class: &str,
197    args: &[crate::config::ArgMapping],
198    field_resolver: &FieldResolver,
199    result_is_simple: bool,
200    is_async: bool,
201    e2e_config: &E2eConfig,
202    enum_fields: &HashMap<String, String>,
203) -> String {
204    let mut out = String::new();
205    out.push_str(&hash::header(CommentStyle::DoubleSlash));
206    // Always import System.Text.Json for the shared JsonOptions field.
207    let _ = writeln!(out, "using System.Text.Json;");
208    let _ = writeln!(out, "using System.Text.Json.Serialization;");
209    let _ = writeln!(out, "using System.Threading.Tasks;");
210    let _ = writeln!(out, "using Xunit;");
211    let _ = writeln!(out, "using {namespace};");
212    let _ = writeln!(out);
213    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
214    let _ = writeln!(out);
215    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
216    let _ = writeln!(out, "public class {test_class}");
217    let _ = writeln!(out, "{{");
218    // Shared options used when deserializing config JSON in test setup.
219    // Mirrors the options used by the library to ensure enum values round-trip correctly.
220    let _ = writeln!(
221        out,
222        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
223    );
224    let _ = writeln!(out);
225
226    for (i, fixture) in fixtures.iter().enumerate() {
227        render_test_method(
228            &mut out,
229            fixture,
230            class_name,
231            function_name,
232            exception_class,
233            result_var,
234            args,
235            field_resolver,
236            result_is_simple,
237            is_async,
238            e2e_config,
239            enum_fields,
240        );
241        if i + 1 < fixtures.len() {
242            let _ = writeln!(out);
243        }
244    }
245
246    let _ = writeln!(out, "}}");
247    out
248}
249
250#[allow(clippy::too_many_arguments)]
251fn render_test_method(
252    out: &mut String,
253    fixture: &Fixture,
254    class_name: &str,
255    _function_name: &str,
256    exception_class: &str,
257    _result_var: &str,
258    _args: &[crate::config::ArgMapping],
259    field_resolver: &FieldResolver,
260    result_is_simple: bool,
261    _is_async: bool,
262    e2e_config: &E2eConfig,
263    enum_fields: &HashMap<String, String>,
264) {
265    let method_name = fixture.id.to_upper_camel_case();
266    let description = &fixture.description;
267    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
268
269    // Resolve call config per-fixture so named calls (e.g. "parse") use the
270    // correct function name, result variable, and async flag.
271    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
272    let lang = "csharp";
273    let cs_overrides = call_config.overrides.get(lang);
274    let effective_function_name = cs_overrides
275        .and_then(|o| o.function.as_ref())
276        .cloned()
277        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
278    let effective_result_var = &call_config.result_var;
279    let effective_is_async = call_config.r#async;
280    let function_name = effective_function_name.as_str();
281    let result_var = effective_result_var.as_str();
282    let is_async = effective_is_async;
283    let args = call_config.args.as_slice();
284
285    // Per-call overrides: result shape, void returns, extra trailing args.
286    let per_call_result_is_simple = cs_overrides.is_some_and(|o| o.result_is_simple);
287    let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
288    let returns_void = call_config.returns_void;
289    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
290    // options_type: prefer per-call override, fall back to top-level csharp override.
291    let top_level_options_type = e2e_config
292        .call
293        .overrides
294        .get("csharp")
295        .and_then(|o| o.options_type.as_deref());
296    let effective_options_type = cs_overrides
297        .and_then(|o| o.options_type.as_deref())
298        .or(top_level_options_type);
299
300    let (mut setup_lines, args_str) = build_args_and_setup(
301        &fixture.input,
302        args,
303        class_name,
304        effective_options_type,
305        enum_fields,
306        &fixture.id,
307    );
308
309    // Build visitor if present and add to setup
310    let mut visitor_arg = String::new();
311    if let Some(visitor_spec) = &fixture.visitor {
312        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
313    }
314
315    let args_with_visitor = if visitor_arg.is_empty() {
316        args_str
317    } else {
318        format!("{args_str}, {visitor_arg}")
319    };
320
321    let final_args = if extra_args_slice.is_empty() {
322        args_with_visitor
323    } else if args_with_visitor.is_empty() {
324        extra_args_slice.join(", ")
325    } else {
326        format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
327    };
328
329    let return_type = if is_async { "async Task" } else { "void" };
330    let await_kw = if is_async { "await " } else { "" };
331
332    let _ = writeln!(out, "    [Fact]");
333    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
334    let _ = writeln!(out, "    {{");
335    let _ = writeln!(out, "        // {description}");
336
337    for line in &setup_lines {
338        let _ = writeln!(out, "        {line}");
339    }
340
341    if expects_error {
342        if is_async {
343            let _ = writeln!(
344                out,
345                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
346            );
347        } else {
348            let _ = writeln!(
349                out,
350                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
351            );
352        }
353        let _ = writeln!(out, "    }}");
354        return;
355    }
356
357    let result_is_vec = cs_overrides.is_some_and(|o| o.result_is_vec);
358
359    if returns_void {
360        let _ = writeln!(out, "        {await_kw}{class_name}.{function_name}({final_args});");
361    } else {
362        let _ = writeln!(
363            out,
364            "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
365        );
366        for assertion in &fixture.assertions {
367            render_assertion(
368                out,
369                assertion,
370                result_var,
371                class_name,
372                exception_class,
373                field_resolver,
374                effective_result_is_simple,
375                result_is_vec,
376            );
377        }
378    }
379
380    let _ = writeln!(out, "    }}");
381}
382
383/// Build setup lines (e.g. handle creation) and the argument list for the function call.
384///
385/// Returns `(setup_lines, args_string)`.
386fn build_args_and_setup(
387    input: &serde_json::Value,
388    args: &[crate::config::ArgMapping],
389    class_name: &str,
390    options_type: Option<&str>,
391    _enum_fields: &HashMap<String, String>,
392    fixture_id: &str,
393) -> (Vec<String>, String) {
394    if args.is_empty() {
395        return (Vec::new(), String::new());
396    }
397
398    let mut setup_lines: Vec<String> = Vec::new();
399    let mut parts: Vec<String> = Vec::new();
400
401    for arg in args {
402        if arg.arg_type == "bytes" {
403            // bytes args must be passed as byte[] in C#.
404            // Treat the fixture value as a UTF-8 string and convert to bytes.
405            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
406            let val = input.get(field);
407            match val {
408                None | Some(serde_json::Value::Null) if arg.optional => {
409                    parts.push("null".to_string());
410                }
411                None | Some(serde_json::Value::Null) => {
412                    parts.push("System.Array.Empty<byte>()".to_string());
413                }
414                Some(v) => {
415                    let cs_str = json_to_csharp(v);
416                    parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
417                }
418            }
419            continue;
420        }
421
422        if arg.arg_type == "mock_url" {
423            setup_lines.push(format!(
424                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
425                arg.name,
426            ));
427            parts.push(arg.name.clone());
428            continue;
429        }
430
431        if arg.arg_type == "handle" {
432            // Generate a CreateEngine (or equivalent) call and pass the variable.
433            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
434            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
435            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
436            if config_value.is_null()
437                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
438            {
439                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
440            } else {
441                // Sort discriminator fields ("type") to appear first in nested objects so
442                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
443                // reading other properties (a requirement as of .NET 8).
444                let sorted = sort_discriminator_first(config_value.clone());
445                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
446                let name = &arg.name;
447                setup_lines.push(format!(
448                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
449                    escape_csharp(&json_str),
450                ));
451                setup_lines.push(format!(
452                    "var {} = {class_name}.{constructor_name}({name}Config);",
453                    arg.name,
454                    name = name,
455                ));
456            }
457            parts.push(arg.name.clone());
458            continue;
459        }
460
461        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
462        let val = input.get(field);
463        match val {
464            None | Some(serde_json::Value::Null) if arg.optional => {
465                // Optional arg with no fixture value: pass null explicitly since
466                // C# nullable parameters still require an argument at the call site.
467                parts.push("null".to_string());
468                continue;
469            }
470            None | Some(serde_json::Value::Null) => {
471                // Required arg with no fixture value: pass a language-appropriate default.
472                let default_val = match arg.arg_type.as_str() {
473                    "string" => "\"\"".to_string(),
474                    "int" | "integer" => "0".to_string(),
475                    "float" | "number" => "0.0d".to_string(),
476                    "bool" | "boolean" => "false".to_string(),
477                    _ => "null".to_string(),
478                };
479                parts.push(default_val);
480            }
481            Some(v) => {
482                if arg.arg_type == "json_object" {
483                    // Array value: generate a typed List<T> based on element_type.
484                    if let Some(arr) = v.as_array() {
485                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
486                        continue;
487                    }
488                    // Object value with known type: deserialize via JsonSerializer so the
489                    // library's own [JsonPropertyName] annotations handle field name mapping.
490                    if let Some(opts_type) = options_type {
491                        if v.is_object() {
492                            let json_str = serde_json::to_string(v).unwrap_or_default();
493                            parts.push(format!(
494                                "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
495                                escape_csharp(&json_str),
496                            ));
497                            continue;
498                        }
499                    }
500                }
501                parts.push(json_to_csharp(v));
502            }
503        }
504    }
505
506    (setup_lines, parts.join(", "))
507}
508
509/// Convert a JSON array to a typed C# `List<T>` expression.
510///
511/// Mapping from `ArgMapping::element_type`:
512/// - `None` or any string type → `List<string>`
513/// - `"f32"` → `List<float>` with `(float)` casts
514/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
515fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
516    match element_type {
517        Some("f32") => {
518            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
519            format!("new List<float>() {{ {} }}", items.join(", "))
520        }
521        Some("(String, String)") => {
522            let items: Vec<String> = arr
523                .iter()
524                .map(|v| {
525                    let strs: Vec<String> = v
526                        .as_array()
527                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
528                    format!("new List<string>() {{ {} }}", strs.join(", "))
529                })
530                .collect();
531            format!("new List<List<string>>() {{ {} }}", items.join(", "))
532        }
533        _ => {
534            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
535            format!("new List<string>() {{ {} }}", items.join(", "))
536        }
537    }
538}
539
540fn render_assertion(
541    out: &mut String,
542    assertion: &Assertion,
543    result_var: &str,
544    class_name: &str,
545    exception_class: &str,
546    field_resolver: &FieldResolver,
547    result_is_simple: bool,
548    result_is_vec: bool,
549) {
550    // Skip assertions on fields that don't exist on the result type.
551    if let Some(f) = &assertion.field {
552        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
553            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
554            return;
555        }
556    }
557
558    // When the result is a List<T>, index into the first element for field access.
559    let effective_result_var: String = if result_is_vec {
560        format!("{result_var}[0]")
561    } else {
562        result_var.to_string()
563    };
564
565    let field_expr = if result_is_simple {
566        effective_result_var.clone()
567    } else {
568        match &assertion.field {
569            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
570            _ => effective_result_var.clone(),
571        }
572    };
573
574    match assertion.assertion_type.as_str() {
575        "equals" => {
576            if let Some(expected) = &assertion.value {
577                let cs_val = json_to_csharp(expected);
578                if expected.is_string() {
579                    // Only call .Trim() on string fields.
580                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
581                } else if expected.as_bool() == Some(true) {
582                    // Boolean true: use Assert.True to avoid xUnit2004 warning.
583                    let _ = writeln!(out, "        Assert.True({field_expr});");
584                } else if expected.as_bool() == Some(false) {
585                    // Boolean false: use Assert.False to avoid xUnit2004 warning.
586                    let _ = writeln!(out, "        Assert.False({field_expr});");
587                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
588                    // Integer values: use Assert.True(x == n) to avoid xUnit overload
589                    // resolution ambiguity (int vs uint vs long vs DateTime).
590                    let _ = writeln!(out, "        Assert.True({field_expr} == {cs_val});");
591                } else {
592                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
593                }
594            }
595        }
596        "contains" => {
597            if let Some(expected) = &assertion.value {
598                // Lowercase both expected and actual so that enum fields (where .ToString()
599                // returns the PascalCase C# member name like "Anchor") correctly match
600                // fixture snake_case values like "anchor".  String fields are unaffected
601                // because lowercasing both sides preserves substring matches.
602                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
603                let cs_val = lower_expected
604                    .as_deref()
605                    .map(|s| format!("\"{}\"", escape_csharp(s)))
606                    .unwrap_or_else(|| json_to_csharp(expected));
607                let _ = writeln!(
608                    out,
609                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
610                );
611            }
612        }
613        "contains_all" => {
614            if let Some(values) = &assertion.values {
615                for val in values {
616                    let lower_val = val.as_str().map(|s| s.to_lowercase());
617                    let cs_val = lower_val
618                        .as_deref()
619                        .map(|s| format!("\"{}\"", escape_csharp(s)))
620                        .unwrap_or_else(|| json_to_csharp(val));
621                    let _ = writeln!(
622                        out,
623                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
624                    );
625                }
626            }
627        }
628        "not_contains" => {
629            if let Some(expected) = &assertion.value {
630                let cs_val = json_to_csharp(expected);
631                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
632            }
633        }
634        "not_empty" => {
635            let _ = writeln!(
636                out,
637                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
638            );
639        }
640        "is_empty" => {
641            let _ = writeln!(
642                out,
643                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
644            );
645        }
646        "contains_any" => {
647            if let Some(values) = &assertion.values {
648                let checks: Vec<String> = values
649                    .iter()
650                    .map(|v| {
651                        let cs_val = json_to_csharp(v);
652                        format!("{field_expr}.ToString().Contains({cs_val})")
653                    })
654                    .collect();
655                let joined = checks.join(" || ");
656                let _ = writeln!(
657                    out,
658                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
659                );
660            }
661        }
662        "greater_than" => {
663            if let Some(val) = &assertion.value {
664                let cs_val = json_to_csharp(val);
665                let _ = writeln!(
666                    out,
667                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
668                );
669            }
670        }
671        "less_than" => {
672            if let Some(val) = &assertion.value {
673                let cs_val = json_to_csharp(val);
674                let _ = writeln!(
675                    out,
676                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
677                );
678            }
679        }
680        "greater_than_or_equal" => {
681            if let Some(val) = &assertion.value {
682                let cs_val = json_to_csharp(val);
683                let _ = writeln!(
684                    out,
685                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
686                );
687            }
688        }
689        "less_than_or_equal" => {
690            if let Some(val) = &assertion.value {
691                let cs_val = json_to_csharp(val);
692                let _ = writeln!(
693                    out,
694                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
695                );
696            }
697        }
698        "starts_with" => {
699            if let Some(expected) = &assertion.value {
700                let cs_val = json_to_csharp(expected);
701                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
702            }
703        }
704        "ends_with" => {
705            if let Some(expected) = &assertion.value {
706                let cs_val = json_to_csharp(expected);
707                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
708            }
709        }
710        "min_length" => {
711            if let Some(val) = &assertion.value {
712                if let Some(n) = val.as_u64() {
713                    let _ = writeln!(
714                        out,
715                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
716                    );
717                }
718            }
719        }
720        "max_length" => {
721            if let Some(val) = &assertion.value {
722                if let Some(n) = val.as_u64() {
723                    let _ = writeln!(
724                        out,
725                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
726                    );
727                }
728            }
729        }
730        "count_min" => {
731            if let Some(val) = &assertion.value {
732                if let Some(n) = val.as_u64() {
733                    let _ = writeln!(
734                        out,
735                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
736                    );
737                }
738            }
739        }
740        "count_equals" => {
741            if let Some(val) = &assertion.value {
742                if let Some(n) = val.as_u64() {
743                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
744                }
745            }
746        }
747        "is_true" => {
748            let _ = writeln!(out, "        Assert.True({field_expr});");
749        }
750        "is_false" => {
751            let _ = writeln!(out, "        Assert.False({field_expr});");
752        }
753        "not_error" => {
754            // Already handled by the call succeeding without exception.
755        }
756        "error" => {
757            // Handled at the test method level.
758        }
759        "method_result" => {
760            if let Some(method_name) = &assertion.method {
761                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
762                let check = assertion.check.as_deref().unwrap_or("is_true");
763                match check {
764                    "equals" => {
765                        if let Some(val) = &assertion.value {
766                            if val.as_bool() == Some(true) {
767                                let _ = writeln!(out, "        Assert.True({call_expr});");
768                            } else if val.as_bool() == Some(false) {
769                                let _ = writeln!(out, "        Assert.False({call_expr});");
770                            } else {
771                                let cs_val = json_to_csharp(val);
772                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
773                            }
774                        }
775                    }
776                    "is_true" => {
777                        let _ = writeln!(out, "        Assert.True({call_expr});");
778                    }
779                    "is_false" => {
780                        let _ = writeln!(out, "        Assert.False({call_expr});");
781                    }
782                    "greater_than_or_equal" => {
783                        if let Some(val) = &assertion.value {
784                            let n = val.as_u64().unwrap_or(0);
785                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
786                        }
787                    }
788                    "count_min" => {
789                        if let Some(val) = &assertion.value {
790                            let n = val.as_u64().unwrap_or(0);
791                            let _ = writeln!(
792                                out,
793                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
794                            );
795                        }
796                    }
797                    "is_error" => {
798                        let _ = writeln!(
799                            out,
800                            "        Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
801                        );
802                    }
803                    "contains" => {
804                        if let Some(val) = &assertion.value {
805                            let cs_val = json_to_csharp(val);
806                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
807                        }
808                    }
809                    other_check => {
810                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
811                    }
812                }
813            } else {
814                panic!("C# e2e generator: method_result assertion missing 'method' field");
815            }
816        }
817        "matches_regex" => {
818            if let Some(expected) = &assertion.value {
819                let cs_val = json_to_csharp(expected);
820                let _ = writeln!(out, "        Assert.Matches({cs_val}, {field_expr});");
821            }
822        }
823        other => {
824            panic!("C# e2e generator: unsupported assertion type: {other}");
825        }
826    }
827}
828
829/// Recursively sort JSON objects so that any key named `"type"` appears first.
830///
831/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
832/// the first property when deserializing polymorphic types. Fixture config values
833/// serialised via serde_json preserve insertion/alphabetical order, which may put
834/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
835fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
836    match value {
837        serde_json::Value::Object(map) => {
838            let mut sorted = serde_json::Map::with_capacity(map.len());
839            // Insert "type" first if present.
840            if let Some(type_val) = map.get("type") {
841                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
842            }
843            for (k, v) in map {
844                if k != "type" {
845                    sorted.insert(k, sort_discriminator_first(v));
846                }
847            }
848            serde_json::Value::Object(sorted)
849        }
850        serde_json::Value::Array(arr) => {
851            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
852        }
853        other => other,
854    }
855}
856
857/// Convert a `serde_json::Value` to a C# literal string.
858fn json_to_csharp(value: &serde_json::Value) -> String {
859    match value {
860        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
861        serde_json::Value::Bool(true) => "true".to_string(),
862        serde_json::Value::Bool(false) => "false".to_string(),
863        serde_json::Value::Number(n) => {
864            if n.is_f64() {
865                format!("{}d", n)
866            } else {
867                n.to_string()
868            }
869        }
870        serde_json::Value::Null => "null".to_string(),
871        serde_json::Value::Array(arr) => {
872            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
873            format!("new[] {{ {} }}", items.join(", "))
874        }
875        serde_json::Value::Object(_) => {
876            let json_str = serde_json::to_string(value).unwrap_or_default();
877            format!("\"{}\"", escape_csharp(&json_str))
878        }
879    }
880}
881
882// ---------------------------------------------------------------------------
883// Visitor generation
884// ---------------------------------------------------------------------------
885
886/// Build a C# visitor class and add setup lines. Returns the visitor variable name.
887fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
888    setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
889    setup_lines.push("class TestVisitor : IVisitor".to_string());
890    setup_lines.push("{".to_string());
891    for (method_name, action) in &visitor_spec.callbacks {
892        emit_csharp_visitor_method(setup_lines, method_name, action);
893    }
894    setup_lines.push("}".to_string());
895    "_testVisitor".to_string()
896}
897
898/// Emit a C# visitor method for a callback action.
899fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
900    let camel_method = method_to_camel(method_name);
901    let params = match method_name {
902        "visit_link" => "VisitContext ctx, string href, string text, string title",
903        "visit_image" => "VisitContext ctx, string src, string alt, string title",
904        "visit_heading" => "VisitContext ctx, int level, string text, string id",
905        "visit_code_block" => "VisitContext ctx, string lang, string code",
906        "visit_code_inline"
907        | "visit_strong"
908        | "visit_emphasis"
909        | "visit_strikethrough"
910        | "visit_underline"
911        | "visit_subscript"
912        | "visit_superscript"
913        | "visit_mark"
914        | "visit_button"
915        | "visit_summary"
916        | "visit_figcaption"
917        | "visit_definition_term"
918        | "visit_definition_description" => "VisitContext ctx, string text",
919        "visit_text" => "VisitContext ctx, string text",
920        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
921        "visit_blockquote" => "VisitContext ctx, string content, int depth",
922        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
923        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
924        "visit_form" => "VisitContext ctx, string actionUrl, string method",
925        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
926        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
927        "visit_details" => "VisitContext ctx, bool isOpen",
928        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
929            "VisitContext ctx, string output"
930        }
931        "visit_list_start" => "VisitContext ctx, bool ordered",
932        "visit_list_end" => "VisitContext ctx, bool ordered, string output",
933        _ => "VisitContext ctx",
934    };
935
936    setup_lines.push(format!("    public VisitResult {camel_method}({params})"));
937    setup_lines.push("    {".to_string());
938    match action {
939        CallbackAction::Skip => {
940            setup_lines.push("        return VisitResult.Skip();".to_string());
941        }
942        CallbackAction::Continue => {
943            setup_lines.push("        return VisitResult.Continue();".to_string());
944        }
945        CallbackAction::PreserveHtml => {
946            setup_lines.push("        return VisitResult.PreserveHtml();".to_string());
947        }
948        CallbackAction::Custom { output } => {
949            let escaped = escape_csharp(output);
950            setup_lines.push(format!("        return VisitResult.Custom(\"{escaped}\");"));
951        }
952        CallbackAction::CustomTemplate { template } => {
953            let escaped = escape_csharp(template);
954            setup_lines.push(format!("        return VisitResult.Custom($\"{escaped}\");"));
955        }
956    }
957    setup_lines.push("    }".to_string());
958}
959
960/// Convert snake_case method names to C# PascalCase.
961fn method_to_camel(snake: &str) -> String {
962    use heck::ToUpperCamelCase;
963    snake.to_upper_camel_case()
964}
965
966/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
967///
968/// Maps well-known method names to the appropriate C# static helper calls on the
969/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
970fn build_csharp_method_call(
971    result_var: &str,
972    method_name: &str,
973    args: Option<&serde_json::Value>,
974    class_name: &str,
975) -> String {
976    match method_name {
977        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
978        "root_node_type" => format!("{result_var}.RootNode.Kind"),
979        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
980        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
981        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
982        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
983        "contains_node_type" => {
984            let node_type = args
985                .and_then(|a| a.get("node_type"))
986                .and_then(|v| v.as_str())
987                .unwrap_or("");
988            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
989        }
990        "find_nodes_by_type" => {
991            let node_type = args
992                .and_then(|a| a.get("node_type"))
993                .and_then(|v| v.as_str())
994                .unwrap_or("");
995            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
996        }
997        "run_query" => {
998            let query_source = args
999                .and_then(|a| a.get("query_source"))
1000                .and_then(|v| v.as_str())
1001                .unwrap_or("");
1002            let language = args
1003                .and_then(|a| a.get("language"))
1004                .and_then(|v| v.as_str())
1005                .unwrap_or("");
1006            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1007        }
1008        _ => {
1009            use heck::ToUpperCamelCase;
1010            let pascal = method_name.to_upper_camel_case();
1011            format!("{result_var}.{pascal}()")
1012        }
1013    }
1014}