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