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 anyhow::Result;
13use heck::ToUpperCamelCase;
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20/// C# e2e code generator.
21pub struct CSharpCodegen;
22
23impl E2eCodegen for CSharpCodegen {
24    fn generate(
25        &self,
26        groups: &[FixtureGroup],
27        e2e_config: &E2eConfig,
28        alef_config: &AlefConfig,
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32
33        let mut files = Vec::new();
34
35        // Resolve call config with overrides.
36        let call = &e2e_config.call;
37        let overrides = call.overrides.get(lang);
38        let function_name = overrides
39            .and_then(|o| o.function.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.function.to_upper_camel_case());
42        let class_name = overrides
43            .and_then(|o| o.class.as_ref())
44            .cloned()
45            .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
46        // The exception class is always {CrateName}Exception, generated by the C# backend.
47        let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
48        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
49            if call.module.is_empty() {
50                "Kreuzberg".to_string()
51            } else {
52                call.module.to_upper_camel_case()
53            }
54        });
55        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
56        let result_var = &call.result_var;
57        let is_async = call.r#async;
58
59        // Resolve package config.
60        let cs_pkg = e2e_config.resolve_package("csharp");
61        let pkg_name = cs_pkg
62            .as_ref()
63            .and_then(|p| p.name.as_ref())
64            .cloned()
65            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
66        // The project reference path uses the crate name (with hyphens) for the directory
67        // and the PascalCase name for the .csproj file.
68        let pkg_path = cs_pkg
69            .as_ref()
70            .and_then(|p| p.path.as_ref())
71            .cloned()
72            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
73        let pkg_version = cs_pkg
74            .as_ref()
75            .and_then(|p| p.version.as_ref())
76            .cloned()
77            .unwrap_or_else(|| "0.1.0".to_string());
78
79        // Generate the .csproj using a unique name derived from the package name so
80        // it does not conflict with any hand-written project files in the same directory.
81        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
82        files.push(GeneratedFile {
83            path: output_base.join(&csproj_name),
84            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
85            generated_header: false,
86        });
87
88        // Generate test files per category.
89        let tests_base = output_base.join("tests");
90        let field_resolver = FieldResolver::new(
91            &e2e_config.fields,
92            &e2e_config.fields_optional,
93            &e2e_config.result_fields,
94            &e2e_config.fields_array,
95        );
96
97        // Resolve enum_fields from C# override config.
98        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
99        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
100
101        for group in groups {
102            let active: Vec<&Fixture> = group
103                .fixtures
104                .iter()
105                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
106                .collect();
107
108            if active.is_empty() {
109                continue;
110            }
111
112            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
113            let filename = format!("{test_class}.cs");
114            let content = render_test_file(
115                &group.category,
116                &active,
117                &namespace,
118                &class_name,
119                &function_name,
120                &exception_class,
121                result_var,
122                &test_class,
123                &e2e_config.call.args,
124                &field_resolver,
125                result_is_simple,
126                is_async,
127                e2e_config,
128                enum_fields,
129            );
130            files.push(GeneratedFile {
131                path: tests_base.join(filename),
132                content,
133                generated_header: true,
134            });
135        }
136
137        Ok(files)
138    }
139
140    fn language_name(&self) -> &'static str {
141        "csharp"
142    }
143}
144
145// ---------------------------------------------------------------------------
146// Rendering
147// ---------------------------------------------------------------------------
148
149fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
150    let pkg_ref = match dep_mode {
151        crate::config::DependencyMode::Registry => {
152            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
153        }
154        crate::config::DependencyMode::Local => {
155            format!("    <ProjectReference Include=\"{pkg_path}\" />")
156        }
157    };
158    format!(
159        r#"<Project Sdk="Microsoft.NET.Sdk">
160  <PropertyGroup>
161    <TargetFramework>net10.0</TargetFramework>
162    <Nullable>enable</Nullable>
163    <ImplicitUsings>enable</ImplicitUsings>
164    <IsPackable>false</IsPackable>
165    <IsTestProject>true</IsTestProject>
166  </PropertyGroup>
167
168  <ItemGroup>
169    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
170    <PackageReference Include="xunit" Version="2.9.3" />
171    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
172  </ItemGroup>
173
174  <ItemGroup>
175{pkg_ref}
176  </ItemGroup>
177</Project>
178"#
179    )
180}
181
182#[allow(clippy::too_many_arguments)]
183fn render_test_file(
184    category: &str,
185    fixtures: &[&Fixture],
186    namespace: &str,
187    class_name: &str,
188    function_name: &str,
189    exception_class: &str,
190    result_var: &str,
191    test_class: &str,
192    args: &[crate::config::ArgMapping],
193    field_resolver: &FieldResolver,
194    result_is_simple: bool,
195    is_async: bool,
196    e2e_config: &E2eConfig,
197    enum_fields: &HashMap<String, String>,
198) -> String {
199    let mut out = String::new();
200    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
201    // Always import System.Text.Json for the shared JsonOptions field.
202    let _ = writeln!(out, "using System.Text.Json;");
203    let _ = writeln!(out, "using System.Text.Json.Serialization;");
204    let _ = writeln!(out, "using System.Threading.Tasks;");
205    let _ = writeln!(out, "using Xunit;");
206    let _ = writeln!(out, "using {namespace};");
207    let _ = writeln!(out);
208    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
209    let _ = writeln!(out);
210    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
211    let _ = writeln!(out, "public class {test_class}");
212    let _ = writeln!(out, "{{");
213    // Shared options used when deserializing config JSON in test setup.
214    // Mirrors the options used by the library to ensure enum values round-trip correctly.
215    let _ = writeln!(
216        out,
217        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
218    );
219    let _ = writeln!(out);
220
221    for (i, fixture) in fixtures.iter().enumerate() {
222        render_test_method(
223            &mut out,
224            fixture,
225            class_name,
226            function_name,
227            exception_class,
228            result_var,
229            args,
230            field_resolver,
231            result_is_simple,
232            is_async,
233            e2e_config,
234            enum_fields,
235        );
236        if i + 1 < fixtures.len() {
237            let _ = writeln!(out);
238        }
239    }
240
241    let _ = writeln!(out, "}}");
242    out
243}
244
245#[allow(clippy::too_many_arguments)]
246fn render_test_method(
247    out: &mut String,
248    fixture: &Fixture,
249    class_name: &str,
250    function_name: &str,
251    exception_class: &str,
252    result_var: &str,
253    args: &[crate::config::ArgMapping],
254    field_resolver: &FieldResolver,
255    result_is_simple: bool,
256    is_async: bool,
257    e2e_config: &E2eConfig,
258    enum_fields: &HashMap<String, String>,
259) {
260    let method_name = fixture.id.to_upper_camel_case();
261    let description = &fixture.description;
262    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
263
264    let (mut setup_lines, args_str) =
265        build_args_and_setup(&fixture.input, args, class_name, e2e_config, enum_fields, &fixture.id);
266
267    // Build visitor if present and add to setup
268    let mut visitor_arg = String::new();
269    if let Some(visitor_spec) = &fixture.visitor {
270        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
271    }
272
273    let final_args = if visitor_arg.is_empty() {
274        args_str
275    } else {
276        format!("{args_str}, {visitor_arg}")
277    };
278
279    let return_type = if is_async { "async Task" } else { "void" };
280    let await_kw = if is_async { "await " } else { "" };
281
282    let _ = writeln!(out, "    [Fact]");
283    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
284    let _ = writeln!(out, "    {{");
285    let _ = writeln!(out, "        // {description}");
286
287    for line in &setup_lines {
288        let _ = writeln!(out, "        {line}");
289    }
290
291    if expects_error {
292        if is_async {
293            let _ = writeln!(
294                out,
295                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
296            );
297        } else {
298            let _ = writeln!(
299                out,
300                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
301            );
302        }
303        let _ = writeln!(out, "    }}");
304        return;
305    }
306
307    let _ = writeln!(
308        out,
309        "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
310    );
311
312    for assertion in &fixture.assertions {
313        render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
314    }
315
316    let _ = writeln!(out, "    }}");
317}
318
319/// Build setup lines (e.g. handle creation) and the argument list for the function call.
320///
321/// Returns `(setup_lines, args_string)`.
322fn build_args_and_setup(
323    input: &serde_json::Value,
324    args: &[crate::config::ArgMapping],
325    class_name: &str,
326    e2e_config: &E2eConfig,
327    enum_fields: &HashMap<String, String>,
328    fixture_id: &str,
329) -> (Vec<String>, String) {
330    if args.is_empty() {
331        return (Vec::new(), json_to_csharp(input));
332    }
333
334    let overrides = e2e_config.call.overrides.get("csharp");
335    let options_type = overrides.and_then(|o| o.options_type.as_deref());
336
337    let mut setup_lines: Vec<String> = Vec::new();
338    let mut parts: Vec<String> = Vec::new();
339
340    for arg in args {
341        if arg.arg_type == "mock_url" {
342            setup_lines.push(format!(
343                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
344                arg.name,
345            ));
346            parts.push(arg.name.clone());
347            continue;
348        }
349
350        if arg.arg_type == "handle" {
351            // Generate a CreateEngine (or equivalent) call and pass the variable.
352            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
353            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
354            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
355            if config_value.is_null()
356                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
357            {
358                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
359            } else {
360                // Sort discriminator fields ("type") to appear first in nested objects so
361                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
362                // reading other properties (a requirement as of .NET 8).
363                let sorted = sort_discriminator_first(config_value.clone());
364                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
365                let name = &arg.name;
366                setup_lines.push(format!(
367                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
368                    escape_csharp(&json_str),
369                ));
370                setup_lines.push(format!(
371                    "var {} = {class_name}.{constructor_name}({name}Config);",
372                    arg.name,
373                    name = name,
374                ));
375            }
376            parts.push(arg.name.clone());
377            continue;
378        }
379
380        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
381        let val = input.get(field);
382        match val {
383            None | Some(serde_json::Value::Null) if arg.optional => {
384                // Optional arg with no fixture value: pass null explicitly since
385                // C# nullable parameters still require an argument at the call site.
386                parts.push("null".to_string());
387                continue;
388            }
389            None | Some(serde_json::Value::Null) => {
390                // Required arg with no fixture value: pass a language-appropriate default.
391                let default_val = match arg.arg_type.as_str() {
392                    "string" => "\"\"".to_string(),
393                    "int" | "integer" => "0".to_string(),
394                    "float" | "number" => "0.0d".to_string(),
395                    "bool" | "boolean" => "false".to_string(),
396                    _ => "null".to_string(),
397                };
398                parts.push(default_val);
399            }
400            Some(v) => {
401                // For json_object args with options_type, construct a typed C# object.
402                if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
403                    if let Some(obj) = v.as_object() {
404                        let props: Vec<String> = obj
405                            .iter()
406                            .map(|(k, vv)| {
407                                let pascal_key = k.to_upper_camel_case();
408                                // Check if this field maps to an enum type.
409                                let cs_val = if let Some(enum_type) = enum_fields.get(k) {
410                                    // Map string value to enum constant (PascalCase).
411                                    if let Some(s) = vv.as_str() {
412                                        let pascal_val = s.to_upper_camel_case();
413                                        format!("{enum_type}.{pascal_val}")
414                                    } else {
415                                        json_to_csharp(vv)
416                                    }
417                                } else {
418                                    json_to_csharp(vv)
419                                };
420                                format!("{pascal_key} = {cs_val}")
421                            })
422                            .collect();
423                        parts.push(format!("new {opts_type} {{ {} }}", props.join(", ")));
424                        continue;
425                    }
426                }
427                parts.push(json_to_csharp(v));
428            }
429        }
430    }
431
432    (setup_lines, parts.join(", "))
433}
434
435fn render_assertion(
436    out: &mut String,
437    assertion: &Assertion,
438    result_var: &str,
439    field_resolver: &FieldResolver,
440    result_is_simple: bool,
441) {
442    // Skip assertions on fields that don't exist on the result type.
443    if let Some(f) = &assertion.field {
444        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
445            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
446            return;
447        }
448    }
449
450    let field_expr = if result_is_simple {
451        result_var.to_string()
452    } else {
453        match &assertion.field {
454            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", result_var),
455            _ => result_var.to_string(),
456        }
457    };
458
459    // Determine whether the field resolves to an optional (nullable) type in C#.
460    let field_is_optional = assertion
461        .field
462        .as_deref()
463        .map(|f| field_resolver.is_optional(field_resolver.resolve(f)))
464        .unwrap_or(false);
465
466    match assertion.assertion_type.as_str() {
467        "equals" => {
468            if let Some(expected) = &assertion.value {
469                let cs_val = json_to_csharp(expected);
470                // Only call .Trim() on string fields, not numeric or boolean ones.
471                if expected.is_string() {
472                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
473                } else if expected.is_number() && field_is_optional {
474                    // Nullable numeric fields require an explicit cast of the expected
475                    // literal so that C# can resolve the overload (e.g. ulong?).
476                    let _ = writeln!(out, "        Assert.Equal((object?){cs_val}, (object?){field_expr});");
477                } else {
478                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
479                }
480            }
481        }
482        "contains" => {
483            if let Some(expected) = &assertion.value {
484                // Lowercase both expected and actual so that enum fields (where .ToString()
485                // returns the PascalCase C# member name like "Anchor") correctly match
486                // fixture snake_case values like "anchor".  String fields are unaffected
487                // because lowercasing both sides preserves substring matches.
488                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
489                let cs_val = lower_expected
490                    .as_deref()
491                    .map(|s| format!("\"{}\"", escape_csharp(s)))
492                    .unwrap_or_else(|| json_to_csharp(expected));
493                let _ = writeln!(
494                    out,
495                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
496                );
497            }
498        }
499        "contains_all" => {
500            if let Some(values) = &assertion.values {
501                for val in values {
502                    let lower_val = val.as_str().map(|s| s.to_lowercase());
503                    let cs_val = lower_val
504                        .as_deref()
505                        .map(|s| format!("\"{}\"", escape_csharp(s)))
506                        .unwrap_or_else(|| json_to_csharp(val));
507                    let _ = writeln!(
508                        out,
509                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
510                    );
511                }
512            }
513        }
514        "not_contains" => {
515            if let Some(expected) = &assertion.value {
516                let cs_val = json_to_csharp(expected);
517                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
518            }
519        }
520        "not_empty" => {
521            let _ = writeln!(
522                out,
523                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
524            );
525        }
526        "is_empty" => {
527            let _ = writeln!(
528                out,
529                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
530            );
531        }
532        "contains_any" => {
533            if let Some(values) = &assertion.values {
534                let checks: Vec<String> = values
535                    .iter()
536                    .map(|v| {
537                        let cs_val = json_to_csharp(v);
538                        format!("{field_expr}.ToString().Contains({cs_val})")
539                    })
540                    .collect();
541                let joined = checks.join(" || ");
542                let _ = writeln!(
543                    out,
544                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
545                );
546            }
547        }
548        "greater_than" => {
549            if let Some(val) = &assertion.value {
550                let cs_val = json_to_csharp(val);
551                let _ = writeln!(
552                    out,
553                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
554                );
555            }
556        }
557        "less_than" => {
558            if let Some(val) = &assertion.value {
559                let cs_val = json_to_csharp(val);
560                let _ = writeln!(
561                    out,
562                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
563                );
564            }
565        }
566        "greater_than_or_equal" => {
567            if let Some(val) = &assertion.value {
568                let cs_val = json_to_csharp(val);
569                let _ = writeln!(
570                    out,
571                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
572                );
573            }
574        }
575        "less_than_or_equal" => {
576            if let Some(val) = &assertion.value {
577                let cs_val = json_to_csharp(val);
578                let _ = writeln!(
579                    out,
580                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
581                );
582            }
583        }
584        "starts_with" => {
585            if let Some(expected) = &assertion.value {
586                let cs_val = json_to_csharp(expected);
587                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
588            }
589        }
590        "ends_with" => {
591            if let Some(expected) = &assertion.value {
592                let cs_val = json_to_csharp(expected);
593                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
594            }
595        }
596        "min_length" => {
597            if let Some(val) = &assertion.value {
598                if let Some(n) = val.as_u64() {
599                    let _ = writeln!(
600                        out,
601                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
602                    );
603                }
604            }
605        }
606        "max_length" => {
607            if let Some(val) = &assertion.value {
608                if let Some(n) = val.as_u64() {
609                    let _ = writeln!(
610                        out,
611                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
612                    );
613                }
614            }
615        }
616        "count_min" => {
617            if let Some(val) = &assertion.value {
618                if let Some(n) = val.as_u64() {
619                    let _ = writeln!(
620                        out,
621                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
622                    );
623                }
624            }
625        }
626        "count_equals" => {
627            if let Some(val) = &assertion.value {
628                if let Some(n) = val.as_u64() {
629                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
630                }
631            }
632        }
633        "is_true" => {
634            let _ = writeln!(out, "        Assert.True({field_expr});");
635        }
636        "not_error" => {
637            // Already handled by the call succeeding without exception.
638        }
639        "error" => {
640            // Handled at the test method level.
641        }
642        other => {
643            let _ = writeln!(out, "        // TODO: unsupported assertion type: {other}");
644        }
645    }
646}
647
648/// Recursively sort JSON objects so that any key named `"type"` appears first.
649///
650/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
651/// the first property when deserializing polymorphic types. Fixture config values
652/// serialised via serde_json preserve insertion/alphabetical order, which may put
653/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
654fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
655    match value {
656        serde_json::Value::Object(map) => {
657            let mut sorted = serde_json::Map::with_capacity(map.len());
658            // Insert "type" first if present.
659            if let Some(type_val) = map.get("type") {
660                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
661            }
662            for (k, v) in map {
663                if k != "type" {
664                    sorted.insert(k, sort_discriminator_first(v));
665                }
666            }
667            serde_json::Value::Object(sorted)
668        }
669        serde_json::Value::Array(arr) => {
670            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
671        }
672        other => other,
673    }
674}
675
676/// Convert a `serde_json::Value` to a C# literal string.
677fn json_to_csharp(value: &serde_json::Value) -> String {
678    match value {
679        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
680        serde_json::Value::Bool(true) => "true".to_string(),
681        serde_json::Value::Bool(false) => "false".to_string(),
682        serde_json::Value::Number(n) => {
683            if n.is_f64() {
684                format!("{}d", n)
685            } else {
686                n.to_string()
687            }
688        }
689        serde_json::Value::Null => "null".to_string(),
690        serde_json::Value::Array(arr) => {
691            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
692            format!("new[] {{ {} }}", items.join(", "))
693        }
694        serde_json::Value::Object(_) => {
695            let json_str = serde_json::to_string(value).unwrap_or_default();
696            format!("\"{}\"", escape_csharp(&json_str))
697        }
698    }
699}
700
701// ---------------------------------------------------------------------------
702// Visitor generation
703// ---------------------------------------------------------------------------
704
705/// Build a C# visitor class and add setup lines. Returns the visitor variable name.
706fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
707    setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
708    setup_lines.push("class TestVisitor : IVisitor".to_string());
709    setup_lines.push("{".to_string());
710    for (method_name, action) in &visitor_spec.callbacks {
711        emit_csharp_visitor_method(setup_lines, method_name, action);
712    }
713    setup_lines.push("}".to_string());
714    "_testVisitor".to_string()
715}
716
717/// Emit a C# visitor method for a callback action.
718fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
719    let camel_method = method_to_camel(method_name);
720    let params = match method_name {
721        "visit_link" => "VisitContext ctx, string href, string text, string title",
722        "visit_image" => "VisitContext ctx, string src, string alt, string title",
723        "visit_heading" => "VisitContext ctx, int level, string text, string id",
724        "visit_code_block" => "VisitContext ctx, string lang, string code",
725        "visit_code_inline"
726        | "visit_strong"
727        | "visit_emphasis"
728        | "visit_strikethrough"
729        | "visit_underline"
730        | "visit_subscript"
731        | "visit_superscript"
732        | "visit_mark"
733        | "visit_button"
734        | "visit_summary"
735        | "visit_figcaption"
736        | "visit_definition_term"
737        | "visit_definition_description" => "VisitContext ctx, string text",
738        "visit_text" => "VisitContext ctx, string text",
739        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
740        "visit_blockquote" => "VisitContext ctx, string content, int depth",
741        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
742        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
743        "visit_form" => "VisitContext ctx, string actionUrl, string method",
744        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
745        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
746        "visit_details" => "VisitContext ctx, bool isOpen",
747        _ => "VisitContext ctx",
748    };
749
750    setup_lines.push(format!("    public VisitResult {camel_method}({params})"));
751    setup_lines.push("    {".to_string());
752    match action {
753        CallbackAction::Skip => {
754            setup_lines.push("        return VisitResult.Skip();".to_string());
755        }
756        CallbackAction::Continue => {
757            setup_lines.push("        return VisitResult.Continue();".to_string());
758        }
759        CallbackAction::PreserveHtml => {
760            setup_lines.push("        return VisitResult.PreserveHtml();".to_string());
761        }
762        CallbackAction::Custom { output } => {
763            let escaped = escape_csharp(output);
764            setup_lines.push(format!("        return VisitResult.Custom(\"{escaped}\");"));
765        }
766        CallbackAction::CustomTemplate { template } => {
767            setup_lines.push(format!("        return VisitResult.Custom($\"{template}\");"));
768        }
769    }
770    setup_lines.push("    }".to_string());
771}
772
773/// Convert snake_case method names to C# PascalCase.
774fn method_to_camel(snake: &str) -> String {
775    use heck::ToUpperCamelCase;
776    snake.to_upper_camel_case()
777}