1use crate::config::E2eConfig;
7use crate::escape::{escape_csharp, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23pub struct CSharpCodegen;
25
26impl E2eCodegen for CSharpCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 config: &ResolvedCrateConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let lang = self.language_name();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36 let mut files = Vec::new();
37
38 let call = &e2e_config.call;
40 let overrides = call.overrides.get(lang);
41 let function_name = overrides
42 .and_then(|o| o.function.as_ref())
43 .cloned()
44 .unwrap_or_else(|| call.function.to_upper_camel_case());
45 let class_name = overrides
46 .and_then(|o| o.class.as_ref())
47 .cloned()
48 .unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
49 let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
51 let namespace = overrides
52 .and_then(|o| o.module.as_ref())
53 .cloned()
54 .or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
55 .unwrap_or_else(|| {
56 if call.module.is_empty() {
57 "Kreuzberg".to_string()
58 } else {
59 call.module.to_upper_camel_case()
60 }
61 });
62 let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
63 let result_var = &call.result_var;
64 let is_async = call.r#async;
65
66 let cs_pkg = e2e_config.resolve_package("csharp");
68 let pkg_name = cs_pkg
69 .as_ref()
70 .and_then(|p| p.name.as_ref())
71 .cloned()
72 .unwrap_or_else(|| config.name.to_upper_camel_case());
73 let pkg_path = cs_pkg
75 .as_ref()
76 .and_then(|p| p.path.as_ref())
77 .cloned()
78 .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
79 let pkg_version = cs_pkg
80 .as_ref()
81 .and_then(|p| p.version.as_ref())
82 .cloned()
83 .unwrap_or_else(|| "0.1.0".to_string());
84
85 let csproj_name = format!("{pkg_name}.E2eTests.csproj");
88 files.push(GeneratedFile {
89 path: output_base.join(&csproj_name),
90 content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
91 generated_header: false,
92 });
93
94 files.push(GeneratedFile {
98 path: output_base.join("TestSetup.cs"),
99 content: render_test_setup(),
100 generated_header: true,
101 });
102
103 let tests_base = output_base.join("tests");
105 let field_resolver = FieldResolver::new(
106 &e2e_config.fields,
107 &e2e_config.fields_optional,
108 &e2e_config.result_fields,
109 &e2e_config.fields_array,
110 &std::collections::HashSet::new(),
111 );
112
113 static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
115 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
116
117 let mut effective_nested_types = default_csharp_nested_types();
119 if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
120 effective_nested_types.extend(overrides_map.clone());
121 }
122
123 for group in groups {
124 let active: Vec<&Fixture> = group
125 .fixtures
126 .iter()
127 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
128 .collect();
129
130 if active.is_empty() {
131 continue;
132 }
133
134 let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
135 let filename = format!("{test_class}.cs");
136 let content = render_test_file(
137 &group.category,
138 &active,
139 &namespace,
140 &class_name,
141 &function_name,
142 &exception_class,
143 result_var,
144 &test_class,
145 &e2e_config.call.args,
146 &field_resolver,
147 result_is_simple,
148 is_async,
149 e2e_config,
150 enum_fields,
151 &effective_nested_types,
152 );
153 files.push(GeneratedFile {
154 path: tests_base.join(filename),
155 content,
156 generated_header: true,
157 });
158 }
159
160 Ok(files)
161 }
162
163 fn language_name(&self) -> &'static str {
164 "csharp"
165 }
166}
167
168fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
173 let pkg_ref = match dep_mode {
174 crate::config::DependencyMode::Registry => {
175 format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
176 }
177 crate::config::DependencyMode::Local => {
178 format!(" <ProjectReference Include=\"{pkg_path}\" />")
179 }
180 };
181 format!(
182 r#"<Project Sdk="Microsoft.NET.Sdk">
183 <PropertyGroup>
184 <TargetFramework>net10.0</TargetFramework>
185 <Nullable>enable</Nullable>
186 <ImplicitUsings>enable</ImplicitUsings>
187 <IsPackable>false</IsPackable>
188 <IsTestProject>true</IsTestProject>
189 </PropertyGroup>
190
191 <ItemGroup>
192 <PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
193 <PackageReference Include="xunit" Version="{xunit}" />
194 <PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
195 </ItemGroup>
196
197 <ItemGroup>
198{pkg_ref}
199 </ItemGroup>
200</Project>
201"#,
202 ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
203 xunit = tv::nuget::XUNIT,
204 xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
205 )
206}
207
208fn render_test_setup() -> String {
209 let mut out = String::new();
210 out.push_str(&hash::header(CommentStyle::DoubleSlash));
211 out.push_str(
212 r#"using System;
213using System.IO;
214using System.Runtime.CompilerServices;
215
216namespace Kreuzberg.E2eTests;
217
218internal static class TestSetup
219{
220 [ModuleInitializer]
221 internal static void Init()
222 {
223 // Walk up from the assembly directory until we find the repo root
224 // (the directory containing test_documents/) so that fixture paths
225 // like "docx/fake.docx" resolve regardless of where dotnet test
226 // launched the runner from.
227 var dir = new DirectoryInfo(AppContext.BaseDirectory);
228 while (dir != null)
229 {
230 var candidate = Path.Combine(dir.FullName, "test_documents");
231 if (Directory.Exists(candidate))
232 {
233 Directory.SetCurrentDirectory(candidate);
234 return;
235 }
236 dir = dir.Parent;
237 }
238 }
239}
240"#,
241 );
242 out
243}
244
245#[allow(clippy::too_many_arguments)]
246fn render_test_file(
247 category: &str,
248 fixtures: &[&Fixture],
249 namespace: &str,
250 class_name: &str,
251 function_name: &str,
252 exception_class: &str,
253 result_var: &str,
254 test_class: &str,
255 args: &[crate::config::ArgMapping],
256 field_resolver: &FieldResolver,
257 result_is_simple: bool,
258 is_async: bool,
259 e2e_config: &E2eConfig,
260 enum_fields: &HashMap<String, String>,
261 nested_types: &HashMap<String, String>,
262) -> String {
263 let mut out = String::new();
264 out.push_str(&hash::header(CommentStyle::DoubleSlash));
265 let _ = writeln!(out, "using System;");
267 let _ = writeln!(out, "using System.Collections.Generic;");
268 let _ = writeln!(out, "using System.Linq;");
269 let _ = writeln!(out, "using System.Net.Http;");
270 let _ = writeln!(out, "using System.Text;");
271 let _ = writeln!(out, "using System.Text.Json;");
272 let _ = writeln!(out, "using System.Text.Json.Serialization;");
273 let _ = writeln!(out, "using System.Threading.Tasks;");
274 let _ = writeln!(out, "using Xunit;");
275 let _ = writeln!(out, "using {namespace};");
276 let _ = writeln!(out, "using static {namespace}.{class_name};");
277 let _ = writeln!(out);
278 let _ = writeln!(out, "namespace Kreuzberg.E2e;");
279 let _ = writeln!(out);
280 let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
281 let _ = writeln!(out, "public class {test_class}");
282 let _ = writeln!(out, "{{");
283 let _ = writeln!(
286 out,
287 " private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
288 );
289 let _ = writeln!(out);
290
291 let mut visitor_class_decls: Vec<String> = Vec::new();
295
296 for (i, fixture) in fixtures.iter().enumerate() {
297 render_test_method(
298 &mut out,
299 &mut visitor_class_decls,
300 fixture,
301 class_name,
302 function_name,
303 exception_class,
304 result_var,
305 args,
306 field_resolver,
307 result_is_simple,
308 is_async,
309 e2e_config,
310 enum_fields,
311 nested_types,
312 );
313 if i + 1 < fixtures.len() {
314 let _ = writeln!(out);
315 }
316 }
317
318 for decl in &visitor_class_decls {
320 let _ = writeln!(out);
321 let _ = writeln!(out, "{decl}");
322 }
323
324 let _ = writeln!(out, "}}");
325 out
326}
327
328struct CSharpTestClientRenderer;
337
338fn to_csharp_http_method(method: &str) -> String {
340 let lower = method.to_ascii_lowercase();
341 let mut chars = lower.chars();
342 match chars.next() {
343 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
344 None => String::new(),
345 }
346}
347
348const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
352 "content-length",
353 "host",
354 "connection",
355 "expect",
356 "transfer-encoding",
357 "upgrade",
358 "content-type",
361 "content-encoding",
363 "content-language",
364 "content-location",
365 "content-md5",
366 "content-range",
367 "content-disposition",
368];
369
370fn is_csharp_content_header(name: &str) -> bool {
374 matches!(
375 name.to_ascii_lowercase().as_str(),
376 "content-type"
377 | "content-length"
378 | "content-encoding"
379 | "content-language"
380 | "content-location"
381 | "content-md5"
382 | "content-range"
383 | "content-disposition"
384 | "expires"
385 | "last-modified"
386 | "allow"
387 )
388}
389
390impl client::TestClientRenderer for CSharpTestClientRenderer {
391 fn language_name(&self) -> &'static str {
392 "csharp"
393 }
394
395 fn sanitize_test_name(&self, id: &str) -> String {
397 id.to_upper_camel_case()
398 }
399
400 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
403 if let Some(reason) = skip_reason {
404 let escaped_reason = escape_csharp(reason);
405 let _ = writeln!(out, " [Fact(Skip = \"{escaped_reason}\")]");
406 let _ = writeln!(out, " public async Task Test_{fn_name}()");
407 } else {
408 let _ = writeln!(out, " [Fact]");
409 let _ = writeln!(out, " public async Task Test_{fn_name}()");
410 }
411 let _ = writeln!(out, " {{");
412 let _ = writeln!(out, " // {description}");
413 }
414
415 fn render_test_close(&self, out: &mut String) {
417 let _ = writeln!(out, " }}");
418 }
419
420 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
425 let method = to_csharp_http_method(ctx.method);
426 let path = escape_csharp(ctx.path);
427
428 let _ = writeln!(
429 out,
430 " var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
431 );
432 let _ = writeln!(
435 out,
436 " using var handler = new System.Net.Http.HttpClientHandler {{ AllowAutoRedirect = false }};"
437 );
438 let _ = writeln!(
439 out,
440 " using var client = new System.Net.Http.HttpClient(handler);"
441 );
442 let _ = writeln!(
443 out,
444 " var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");"
445 );
446
447 if let Some(body) = ctx.body {
449 let content_type = ctx.content_type.unwrap_or("application/json");
450 let json_str = serde_json::to_string(body).unwrap_or_default();
451 let escaped = escape_csharp(&json_str);
452 let _ = writeln!(
453 out,
454 " request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
455 );
456 }
457
458 for (name, value) in ctx.headers {
460 if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
461 continue;
462 }
463 let escaped_name = escape_csharp(name);
464 let escaped_value = escape_csharp(value);
465 let _ = writeln!(
466 out,
467 " request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
468 );
469 }
470
471 if !ctx.cookies.is_empty() {
473 let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
474 pairs.sort();
475 let cookie_header = escape_csharp(&pairs.join("; "));
476 let _ = writeln!(out, " request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
477 }
478
479 let _ = writeln!(out, " var response = await client.SendAsync(request);");
480 }
481
482 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
484 let _ = writeln!(out, " Assert.Equal({status}, (int)response.StatusCode);");
485 }
486
487 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
492 let target = if is_csharp_content_header(name) {
493 "response.Content.Headers"
494 } else {
495 "response.Headers"
496 };
497 let escaped_name = escape_csharp(name);
498 match expected {
499 "<<present>>" => {
500 let _ = writeln!(
501 out,
502 " Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");"
503 );
504 }
505 "<<absent>>" => {
506 let _ = writeln!(
507 out,
508 " Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");"
509 );
510 }
511 "<<uuid>>" => {
512 let _ = writeln!(
514 out,
515 " Assert.True({target}.TryGetValues(\"{escaped_name}\", out var _uuidHdr) && System.Text.RegularExpressions.Regex.IsMatch(string.Join(\", \", _uuidHdr), @\"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$\"), \"header {escaped_name} is not a UUID\");"
516 );
517 }
518 literal => {
519 let var_name = format!("hdr{}", sanitize_ident(name));
522 let escaped_value = escape_csharp(literal);
523 let _ = writeln!(
524 out,
525 " Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
526 );
527 }
528 }
529 }
530
531 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
535 match expected {
536 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
537 let json_str = serde_json::to_string(expected).unwrap_or_default();
538 let escaped = escape_csharp(&json_str);
539 let _ = writeln!(
540 out,
541 " var bodyText = await response.Content.ReadAsStringAsync();"
542 );
543 let _ = writeln!(out, " var body = JsonDocument.Parse(bodyText).RootElement;");
544 let _ = writeln!(
545 out,
546 " var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
547 );
548 let _ = writeln!(
549 out,
550 " Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
551 );
552 }
553 serde_json::Value::String(s) => {
554 let escaped = escape_csharp(s);
555 let _ = writeln!(
556 out,
557 " var bodyText = await response.Content.ReadAsStringAsync();"
558 );
559 let _ = writeln!(out, " Assert.Equal(\"{escaped}\", bodyText.Trim());");
560 }
561 other => {
562 let escaped = escape_csharp(&other.to_string());
563 let _ = writeln!(
564 out,
565 " var bodyText = await response.Content.ReadAsStringAsync();"
566 );
567 let _ = writeln!(out, " Assert.Equal(\"{escaped}\", bodyText.Trim());");
568 }
569 }
570 }
571
572 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
577 if let Some(obj) = expected.as_object() {
578 let _ = writeln!(
579 out,
580 " var partialBodyText = await response.Content.ReadAsStringAsync();"
581 );
582 let _ = writeln!(
583 out,
584 " var partialBody = JsonDocument.Parse(partialBodyText).RootElement;"
585 );
586 for (key, val) in obj {
587 let escaped_key = escape_csharp(key);
588 let json_str = serde_json::to_string(val).unwrap_or_default();
589 let escaped_val = escape_csharp(&json_str);
590 let var_name = format!("expected{}", key.to_upper_camel_case());
591 let _ = writeln!(
592 out,
593 " var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;"
594 );
595 let _ = writeln!(
596 out,
597 " Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");"
598 );
599 }
600 }
601 }
602
603 fn render_assert_validation_errors(
606 &self,
607 out: &mut String,
608 _response_var: &str,
609 errors: &[ValidationErrorExpectation],
610 ) {
611 let _ = writeln!(
612 out,
613 " var validationBodyText = await response.Content.ReadAsStringAsync();"
614 );
615 for err in errors {
616 let escaped_msg = escape_csharp(&err.msg);
617 let _ = writeln!(out, " Assert.Contains(\"{escaped_msg}\", validationBodyText);");
618 }
619 }
620}
621
622fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
625 client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
626}
627
628#[allow(clippy::too_many_arguments)]
629fn render_test_method(
630 out: &mut String,
631 visitor_class_decls: &mut Vec<String>,
632 fixture: &Fixture,
633 class_name: &str,
634 _function_name: &str,
635 exception_class: &str,
636 _result_var: &str,
637 _args: &[crate::config::ArgMapping],
638 field_resolver: &FieldResolver,
639 result_is_simple: bool,
640 _is_async: bool,
641 e2e_config: &E2eConfig,
642 enum_fields: &HashMap<String, String>,
643 nested_types: &HashMap<String, String>,
644) {
645 let method_name = fixture.id.to_upper_camel_case();
646 let description = &fixture.description;
647
648 if let Some(http) = &fixture.http {
650 render_http_test_method(out, fixture, http);
651 return;
652 }
653
654 if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
657 let _ = writeln!(
658 out,
659 " [Fact(Skip = \"non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function\")]"
660 );
661 let _ = writeln!(out, " public void Test_{method_name}()");
662 let _ = writeln!(out, " {{");
663 let _ = writeln!(out, " // {description}");
664 let _ = writeln!(out, " }}");
665 return;
666 }
667
668 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
669
670 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
673 let lang = "csharp";
674 let cs_overrides = call_config.overrides.get(lang);
675 let effective_function_name = cs_overrides
676 .and_then(|o| o.function.as_ref())
677 .cloned()
678 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
679 let effective_result_var = &call_config.result_var;
680 let effective_is_async = call_config.r#async;
681 let function_name = effective_function_name.as_str();
682 let result_var = effective_result_var.as_str();
683 let is_async = effective_is_async;
684 let args = call_config.args.as_slice();
685
686 let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
690 let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
691 let returns_void = call_config.returns_void;
692 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
693 let top_level_options_type = e2e_config
695 .call
696 .overrides
697 .get("csharp")
698 .and_then(|o| o.options_type.as_deref());
699 let effective_options_type = cs_overrides
700 .and_then(|o| o.options_type.as_deref())
701 .or(top_level_options_type);
702
703 let (mut setup_lines, args_str) = build_args_and_setup(
704 &fixture.input,
705 args,
706 class_name,
707 effective_options_type,
708 enum_fields,
709 nested_types,
710 &fixture.id,
711 );
712
713 let mut visitor_arg = String::new();
715 let has_visitor = fixture.visitor.is_some();
716 if let Some(visitor_spec) = &fixture.visitor {
717 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
718 }
719
720 let final_args = if has_visitor && !visitor_arg.is_empty() {
724 let opts_type = effective_options_type.unwrap_or("ConversionOptions");
725 if args_str.contains("JsonSerializer.Deserialize") {
726 setup_lines.push(format!("var options = {args_str};"));
728 setup_lines.push(format!("options.Visitor = {visitor_arg};"));
729 "options".to_string()
730 } else if args_str.ends_with(", null") {
731 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
733 let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
735 } else if args_str.contains(", null,") {
736 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
738 args_str.replace(", null,", ", options,")
739 } else if args_str.is_empty() {
740 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
742 "options".to_string()
743 } else {
744 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
746 format!("{args_str}, options")
747 }
748 } else if extra_args_slice.is_empty() {
749 args_str
750 } else if args_str.is_empty() {
751 extra_args_slice.join(", ")
752 } else {
753 format!("{args_str}, {}", extra_args_slice.join(", "))
754 };
755
756 let effective_function_name = function_name.to_string();
759
760 let return_type = if is_async { "async Task" } else { "void" };
761 let await_kw = if is_async { "await " } else { "" };
762
763 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
766 e2e_config
767 .call
768 .overrides
769 .get("csharp")
770 .and_then(|o| o.client_factory.as_deref())
771 });
772 let call_target = if client_factory.is_some() {
773 "client".to_string()
774 } else {
775 class_name.to_string()
776 };
777
778 let _ = writeln!(out, " [Fact]");
779 let _ = writeln!(out, " public {return_type} Test_{method_name}()");
780 let _ = writeln!(out, " {{");
781 let _ = writeln!(out, " // {description}");
782
783 for line in &setup_lines {
784 let _ = writeln!(out, " {line}");
785 }
786
787 if let Some(factory) = client_factory {
789 let factory_name = factory.to_upper_camel_case();
790 let fixture_id = &fixture.id;
791 let _ = writeln!(
792 out,
793 " var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";"
794 );
795 let _ = writeln!(
796 out,
797 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);"
798 );
799 }
800
801 if expects_error {
802 if is_async {
803 let _ = writeln!(
804 out,
805 " await Assert.ThrowsAnyAsync<{exception_class}>(() => {call_target}.{effective_function_name}({final_args}));"
806 );
807 } else {
808 let _ = writeln!(
809 out,
810 " Assert.ThrowsAny<{exception_class}>(() => {call_target}.{effective_function_name}({final_args}));"
811 );
812 }
813 let _ = writeln!(out, " }}");
814 return;
815 }
816
817 let result_is_vec = call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec);
818 let result_is_array = call_config.result_is_array;
819
820 if returns_void {
821 let _ = writeln!(
822 out,
823 " {await_kw}{call_target}.{effective_function_name}({final_args});"
824 );
825 } else {
826 let _ = writeln!(
827 out,
828 " var {result_var} = {await_kw}{call_target}.{effective_function_name}({final_args});"
829 );
830 for assertion in &fixture.assertions {
831 render_assertion(
832 out,
833 assertion,
834 result_var,
835 class_name,
836 exception_class,
837 field_resolver,
838 effective_result_is_simple,
839 result_is_vec,
840 result_is_array,
841 );
842 }
843 }
844
845 let _ = writeln!(out, " }}");
846}
847
848fn build_args_and_setup(
852 input: &serde_json::Value,
853 args: &[crate::config::ArgMapping],
854 class_name: &str,
855 options_type: Option<&str>,
856 enum_fields: &HashMap<String, String>,
857 nested_types: &HashMap<String, String>,
858 fixture_id: &str,
859) -> (Vec<String>, String) {
860 if args.is_empty() {
861 return (Vec::new(), String::new());
862 }
863
864 let mut setup_lines: Vec<String> = Vec::new();
865 let mut parts: Vec<String> = Vec::new();
866
867 for arg in args {
868 if arg.arg_type == "bytes" {
869 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
872 let val = input.get(field);
873 match val {
874 None | Some(serde_json::Value::Null) if arg.optional => {
875 parts.push("null".to_string());
876 }
877 None | Some(serde_json::Value::Null) => {
878 parts.push("System.Array.Empty<byte>()".to_string());
879 }
880 Some(v) => {
881 let cs_str = json_to_csharp(v);
882 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
883 }
884 }
885 continue;
886 }
887
888 if arg.arg_type == "mock_url" {
889 setup_lines.push(format!(
890 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
891 arg.name,
892 ));
893 parts.push(arg.name.clone());
894 continue;
895 }
896
897 if arg.arg_type == "handle" {
898 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
900 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
901 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
902 if config_value.is_null()
903 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
904 {
905 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
906 } else {
907 let sorted = sort_discriminator_first(config_value.clone());
911 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
912 let name = &arg.name;
913 setup_lines.push(format!(
914 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
915 escape_csharp(&json_str),
916 ));
917 setup_lines.push(format!(
918 "var {} = {class_name}.{constructor_name}({name}Config);",
919 arg.name,
920 name = name,
921 ));
922 }
923 parts.push(arg.name.clone());
924 continue;
925 }
926
927 let val: Option<&serde_json::Value> = if arg.field == "input" {
930 Some(input)
931 } else {
932 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
933 input.get(field)
934 };
935 match val {
936 None | Some(serde_json::Value::Null) if arg.optional => {
937 parts.push("null".to_string());
940 continue;
941 }
942 None | Some(serde_json::Value::Null) => {
943 let default_val = match arg.arg_type.as_str() {
947 "string" => "\"\"".to_string(),
948 "int" | "integer" => "0".to_string(),
949 "float" | "number" => "0.0d".to_string(),
950 "bool" | "boolean" => "false".to_string(),
951 "json_object" => {
952 if let Some(opts_type) = options_type {
953 format!("new {opts_type}()")
954 } else {
955 "null".to_string()
956 }
957 }
958 _ => "null".to_string(),
959 };
960 parts.push(default_val);
961 }
962 Some(v) => {
963 if arg.arg_type == "json_object" {
964 if let Some(arr) = v.as_array() {
966 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
967 continue;
968 }
969 if let Some(opts_type) = options_type {
971 if let Some(obj) = v.as_object() {
972 parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
973 continue;
974 }
975 }
976 }
977 parts.push(json_to_csharp(v));
978 }
979 }
980 }
981
982 (setup_lines, parts.join(", "))
983}
984
985fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
993 match element_type {
994 Some("BatchBytesItem") => {
995 let items: Vec<String> = arr
996 .iter()
997 .filter_map(|v| v.as_object())
998 .map(|obj| {
999 let content = obj.get("content").and_then(|v| v.as_array());
1000 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1001 let content_code = if let Some(arr) = content {
1002 let bytes: Vec<String> = arr
1003 .iter()
1004 .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1005 .collect();
1006 format!("new byte[] {{ {} }}", bytes.join(", "))
1007 } else {
1008 "new byte[] { }".to_string()
1009 };
1010 format!(
1011 "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1012 content_code, mime_type
1013 )
1014 })
1015 .collect();
1016 format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1017 }
1018 Some("BatchFileItem") => {
1019 let items: Vec<String> = arr
1020 .iter()
1021 .filter_map(|v| v.as_object())
1022 .map(|obj| {
1023 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1024 format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1025 })
1026 .collect();
1027 format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1028 }
1029 Some("f32") => {
1030 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1031 format!("new List<float>() {{ {} }}", items.join(", "))
1032 }
1033 Some("(String, String)") => {
1034 let items: Vec<String> = arr
1035 .iter()
1036 .map(|v| {
1037 let strs: Vec<String> = v
1038 .as_array()
1039 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1040 format!("new List<string>() {{ {} }}", strs.join(", "))
1041 })
1042 .collect();
1043 format!("new List<List<string>>() {{ {} }}", items.join(", "))
1044 }
1045 Some(et)
1046 if et != "f32"
1047 && et != "(String, String)"
1048 && et != "string"
1049 && et != "BatchBytesItem"
1050 && et != "BatchFileItem" =>
1051 {
1052 let items: Vec<String> = arr
1054 .iter()
1055 .map(|v| {
1056 let json_str = serde_json::to_string(v).unwrap_or_default();
1057 let escaped = escape_csharp(&json_str);
1058 format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1059 })
1060 .collect();
1061 format!("new List<{et}>() {{ {} }}", items.join(", "))
1062 }
1063 _ => {
1064 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1065 format!("new List<string>() {{ {} }}", items.join(", "))
1066 }
1067 }
1068}
1069
1070#[allow(clippy::too_many_arguments)]
1071fn render_assertion(
1072 out: &mut String,
1073 assertion: &Assertion,
1074 result_var: &str,
1075 class_name: &str,
1076 exception_class: &str,
1077 field_resolver: &FieldResolver,
1078 result_is_simple: bool,
1079 result_is_vec: bool,
1080 result_is_array: bool,
1081) {
1082 if let Some(f) = &assertion.field {
1085 match f.as_str() {
1086 "chunks_have_content" => {
1087 let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
1088 match assertion.assertion_type.as_str() {
1089 "is_true" => {
1090 let _ = writeln!(out, " Assert.True({pred});");
1091 }
1092 "is_false" => {
1093 let _ = writeln!(out, " Assert.False({pred});");
1094 }
1095 _ => {
1096 let _ = writeln!(
1097 out,
1098 " // skipped: unsupported assertion type on synthetic field '{f}'"
1099 );
1100 }
1101 }
1102 return;
1103 }
1104 "chunks_have_embeddings" => {
1105 let pred =
1106 format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
1107 match assertion.assertion_type.as_str() {
1108 "is_true" => {
1109 let _ = writeln!(out, " Assert.True({pred});");
1110 }
1111 "is_false" => {
1112 let _ = writeln!(out, " Assert.False({pred});");
1113 }
1114 _ => {
1115 let _ = writeln!(
1116 out,
1117 " // skipped: unsupported assertion type on synthetic field '{f}'"
1118 );
1119 }
1120 }
1121 return;
1122 }
1123 "embeddings" => {
1127 match assertion.assertion_type.as_str() {
1128 "count_equals" => {
1129 if let Some(val) = &assertion.value {
1130 let cs_val = json_to_csharp(val);
1131 let _ = writeln!(out, " Assert.True({result_var}.Count == {cs_val});");
1132 }
1133 }
1134 "count_min" => {
1135 if let Some(val) = &assertion.value {
1136 let cs_val = json_to_csharp(val);
1137 let _ = writeln!(out, " Assert.True({result_var}.Count >= {cs_val});");
1138 }
1139 }
1140 "not_empty" => {
1141 let _ = writeln!(out, " Assert.NotEmpty({result_var});");
1142 }
1143 "is_empty" => {
1144 let _ = writeln!(out, " Assert.Empty({result_var});");
1145 }
1146 _ => {
1147 let _ = writeln!(
1148 out,
1149 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
1150 );
1151 }
1152 }
1153 return;
1154 }
1155 "embedding_dimensions" => {
1156 let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
1157 match assertion.assertion_type.as_str() {
1158 "equals" => {
1159 if let Some(val) = &assertion.value {
1160 let cs_val = json_to_csharp(val);
1161 let _ = writeln!(out, " Assert.True({expr} == {cs_val});");
1162 }
1163 }
1164 "greater_than" => {
1165 if let Some(val) = &assertion.value {
1166 let cs_val = json_to_csharp(val);
1167 let _ = writeln!(out, " Assert.True({expr} > {cs_val});");
1168 }
1169 }
1170 _ => {
1171 let _ = writeln!(
1172 out,
1173 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1174 );
1175 }
1176 }
1177 return;
1178 }
1179 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1180 let pred = match f.as_str() {
1181 "embeddings_valid" => {
1182 format!("{result_var}.All(e => e.Count > 0)")
1183 }
1184 "embeddings_finite" => {
1185 format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
1186 }
1187 "embeddings_non_zero" => {
1188 format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
1189 }
1190 "embeddings_normalized" => {
1191 format!(
1192 "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
1193 )
1194 }
1195 _ => unreachable!(),
1196 };
1197 match assertion.assertion_type.as_str() {
1198 "is_true" => {
1199 let _ = writeln!(out, " Assert.True({pred});");
1200 }
1201 "is_false" => {
1202 let _ = writeln!(out, " Assert.False({pred});");
1203 }
1204 _ => {
1205 let _ = writeln!(
1206 out,
1207 " // skipped: unsupported assertion type on synthetic field '{f}'"
1208 );
1209 }
1210 }
1211 return;
1212 }
1213 "keywords" | "keywords_count" => {
1216 let _ = writeln!(
1217 out,
1218 " // skipped: field '{f}' not available on C# ExtractionResult"
1219 );
1220 return;
1221 }
1222 _ => {}
1223 }
1224 }
1225
1226 if let Some(f) = &assertion.field {
1228 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1229 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1230 return;
1231 }
1232 }
1233
1234 let is_count_assertion = matches!(
1237 assertion.assertion_type.as_str(),
1238 "count_equals" | "count_min" | "count_max"
1239 );
1240 let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
1241 let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
1242
1243 let effective_result_var: String = if result_is_vec && !use_list_directly {
1244 format!("{result_var}[0]")
1245 } else {
1246 result_var.to_string()
1247 };
1248
1249 let field_expr = if result_is_simple {
1250 effective_result_var.clone()
1251 } else {
1252 match &assertion.field {
1253 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
1254 _ => effective_result_var.clone(),
1255 }
1256 };
1257
1258 let field_needs_json_serialize = if result_is_simple {
1262 result_is_array
1265 } else {
1266 match &assertion.field {
1267 Some(f) if !f.is_empty() => field_resolver.is_array(f),
1268 _ => !result_is_simple,
1270 }
1271 };
1272 let field_as_str = if field_needs_json_serialize {
1274 format!("JsonSerializer.Serialize({field_expr})")
1275 } else {
1276 format!("{field_expr}.ToString()")
1277 };
1278
1279 match assertion.assertion_type.as_str() {
1280 "equals" => {
1281 if let Some(expected) = &assertion.value {
1282 let cs_val = json_to_csharp(expected);
1283 if expected.is_string() {
1284 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}!.Trim());");
1286 } else if expected.as_bool() == Some(true) {
1287 let _ = writeln!(out, " Assert.True({field_expr});");
1289 } else if expected.as_bool() == Some(false) {
1290 let _ = writeln!(out, " Assert.False({field_expr});");
1292 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1293 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
1296 } else {
1297 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
1298 }
1299 }
1300 }
1301 "contains" => {
1302 if let Some(expected) = &assertion.value {
1303 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1310 let cs_val = lower_expected
1311 .as_deref()
1312 .map(|s| format!("\"{}\"", escape_csharp(s)))
1313 .unwrap_or_else(|| json_to_csharp(expected));
1314 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1315 }
1316 }
1317 "contains_all" => {
1318 if let Some(values) = &assertion.values {
1319 for val in values {
1320 let lower_val = val.as_str().map(|s| s.to_lowercase());
1321 let cs_val = lower_val
1322 .as_deref()
1323 .map(|s| format!("\"{}\"", escape_csharp(s)))
1324 .unwrap_or_else(|| json_to_csharp(val));
1325 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1326 }
1327 }
1328 }
1329 "not_contains" => {
1330 if let Some(expected) = &assertion.value {
1331 let cs_val = json_to_csharp(expected);
1332 let _ = writeln!(out, " Assert.DoesNotContain({cs_val}, {field_as_str});");
1333 }
1334 }
1335 "not_empty" => {
1336 if field_needs_json_serialize {
1337 let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
1338 } else {
1339 let _ = writeln!(
1340 out,
1341 " Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
1342 );
1343 }
1344 }
1345 "is_empty" => {
1346 if field_needs_json_serialize {
1347 let _ = writeln!(out, " Assert.Empty({field_expr});");
1348 } else {
1349 let _ = writeln!(
1350 out,
1351 " Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
1352 );
1353 }
1354 }
1355 "contains_any" => {
1356 if let Some(values) = &assertion.values {
1357 let checks: Vec<String> = values
1358 .iter()
1359 .map(|v| {
1360 let cs_val = json_to_csharp(v);
1361 format!("{field_as_str}.Contains({cs_val})")
1362 })
1363 .collect();
1364 let joined = checks.join(" || ");
1365 let _ = writeln!(
1366 out,
1367 " Assert.True({joined}, \"expected to contain at least one of the specified values\");"
1368 );
1369 }
1370 }
1371 "greater_than" => {
1372 if let Some(val) = &assertion.value {
1373 let cs_val = json_to_csharp(val);
1374 let _ = writeln!(
1375 out,
1376 " Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
1377 );
1378 }
1379 }
1380 "less_than" => {
1381 if let Some(val) = &assertion.value {
1382 let cs_val = json_to_csharp(val);
1383 let _ = writeln!(
1384 out,
1385 " Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
1386 );
1387 }
1388 }
1389 "greater_than_or_equal" => {
1390 if let Some(val) = &assertion.value {
1391 let cs_val = json_to_csharp(val);
1392 let _ = writeln!(
1393 out,
1394 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1395 );
1396 }
1397 }
1398 "less_than_or_equal" => {
1399 if let Some(val) = &assertion.value {
1400 let cs_val = json_to_csharp(val);
1401 let _ = writeln!(
1402 out,
1403 " Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
1404 );
1405 }
1406 }
1407 "starts_with" => {
1408 if let Some(expected) = &assertion.value {
1409 let cs_val = json_to_csharp(expected);
1410 let _ = writeln!(out, " Assert.StartsWith({cs_val}, {field_expr});");
1411 }
1412 }
1413 "ends_with" => {
1414 if let Some(expected) = &assertion.value {
1415 let cs_val = json_to_csharp(expected);
1416 let _ = writeln!(out, " Assert.EndsWith({cs_val}, {field_expr});");
1417 }
1418 }
1419 "min_length" => {
1420 if let Some(val) = &assertion.value {
1421 if let Some(n) = val.as_u64() {
1422 let _ = writeln!(
1423 out,
1424 " Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
1425 );
1426 }
1427 }
1428 }
1429 "max_length" => {
1430 if let Some(val) = &assertion.value {
1431 if let Some(n) = val.as_u64() {
1432 let _ = writeln!(
1433 out,
1434 " Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
1435 );
1436 }
1437 }
1438 }
1439 "count_min" => {
1440 if let Some(val) = &assertion.value {
1441 if let Some(n) = val.as_u64() {
1442 let _ = writeln!(
1443 out,
1444 " Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
1445 );
1446 }
1447 }
1448 }
1449 "count_equals" => {
1450 if let Some(val) = &assertion.value {
1451 if let Some(n) = val.as_u64() {
1452 let _ = writeln!(out, " Assert.Equal({n}, {field_expr}.Count);");
1453 }
1454 }
1455 }
1456 "is_true" => {
1457 let _ = writeln!(out, " Assert.True({field_expr});");
1458 }
1459 "is_false" => {
1460 let _ = writeln!(out, " Assert.False({field_expr});");
1461 }
1462 "not_error" => {
1463 }
1465 "error" => {
1466 }
1468 "method_result" => {
1469 if let Some(method_name) = &assertion.method {
1470 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1471 let check = assertion.check.as_deref().unwrap_or("is_true");
1472 match check {
1473 "equals" => {
1474 if let Some(val) = &assertion.value {
1475 if val.as_bool() == Some(true) {
1476 let _ = writeln!(out, " Assert.True({call_expr});");
1477 } else if val.as_bool() == Some(false) {
1478 let _ = writeln!(out, " Assert.False({call_expr});");
1479 } else {
1480 let cs_val = json_to_csharp(val);
1481 let _ = writeln!(out, " Assert.Equal({cs_val}, {call_expr});");
1482 }
1483 }
1484 }
1485 "is_true" => {
1486 let _ = writeln!(out, " Assert.True({call_expr});");
1487 }
1488 "is_false" => {
1489 let _ = writeln!(out, " Assert.False({call_expr});");
1490 }
1491 "greater_than_or_equal" => {
1492 if let Some(val) = &assertion.value {
1493 let n = val.as_u64().unwrap_or(0);
1494 let _ = writeln!(out, " Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
1495 }
1496 }
1497 "count_min" => {
1498 if let Some(val) = &assertion.value {
1499 let n = val.as_u64().unwrap_or(0);
1500 let _ = writeln!(
1501 out,
1502 " Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
1503 );
1504 }
1505 }
1506 "is_error" => {
1507 let _ = writeln!(
1508 out,
1509 " Assert.ThrowsAny<{exception_class}>(() => {{ {call_expr}; }});"
1510 );
1511 }
1512 "contains" => {
1513 if let Some(val) = &assertion.value {
1514 let cs_val = json_to_csharp(val);
1515 let _ = writeln!(out, " Assert.Contains({cs_val}, {call_expr});");
1516 }
1517 }
1518 other_check => {
1519 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1520 }
1521 }
1522 } else {
1523 panic!("C# e2e generator: method_result assertion missing 'method' field");
1524 }
1525 }
1526 "matches_regex" => {
1527 if let Some(expected) = &assertion.value {
1528 let cs_val = json_to_csharp(expected);
1529 let _ = writeln!(out, " Assert.Matches({cs_val}, {field_expr});");
1530 }
1531 }
1532 other => {
1533 panic!("C# e2e generator: unsupported assertion type: {other}");
1534 }
1535 }
1536}
1537
1538fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1545 match value {
1546 serde_json::Value::Object(map) => {
1547 let mut sorted = serde_json::Map::with_capacity(map.len());
1548 if let Some(type_val) = map.get("type") {
1550 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1551 }
1552 for (k, v) in map {
1553 if k != "type" {
1554 sorted.insert(k, sort_discriminator_first(v));
1555 }
1556 }
1557 serde_json::Value::Object(sorted)
1558 }
1559 serde_json::Value::Array(arr) => {
1560 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1561 }
1562 other => other,
1563 }
1564}
1565
1566fn json_to_csharp(value: &serde_json::Value) -> String {
1568 match value {
1569 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1570 serde_json::Value::Bool(true) => "true".to_string(),
1571 serde_json::Value::Bool(false) => "false".to_string(),
1572 serde_json::Value::Number(n) => {
1573 if n.is_f64() {
1574 format!("{}d", n)
1575 } else {
1576 n.to_string()
1577 }
1578 }
1579 serde_json::Value::Null => "null".to_string(),
1580 serde_json::Value::Array(arr) => {
1581 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1582 format!("new[] {{ {} }}", items.join(", "))
1583 }
1584 serde_json::Value::Object(_) => {
1585 let json_str = serde_json::to_string(value).unwrap_or_default();
1586 format!("\"{}\"", escape_csharp(&json_str))
1587 }
1588 }
1589}
1590
1591fn default_csharp_nested_types() -> HashMap<String, String> {
1598 [
1599 ("chunking", "ChunkingConfig"),
1600 ("ocr", "OcrConfig"),
1601 ("images", "ImageExtractionConfig"),
1602 ("html_output", "HtmlOutputConfig"),
1603 ("language_detection", "LanguageDetectionConfig"),
1604 ("postprocessor", "PostProcessorConfig"),
1605 ("acceleration", "AccelerationConfig"),
1606 ("email", "EmailConfig"),
1607 ("pages", "PageConfig"),
1608 ("pdf_options", "PdfConfig"),
1609 ("layout", "LayoutDetectionConfig"),
1610 ("tree_sitter", "TreeSitterConfig"),
1611 ("structured_extraction", "StructuredExtractionConfig"),
1612 ("content_filter", "ContentFilterConfig"),
1613 ("token_reduction", "TokenReductionOptions"),
1614 ("security_limits", "SecurityLimits"),
1615 ]
1616 .iter()
1617 .map(|(k, v)| (k.to_string(), v.to_string()))
1618 .collect()
1619}
1620
1621fn csharp_object_initializer(
1629 obj: &serde_json::Map<String, serde_json::Value>,
1630 type_name: &str,
1631 enum_fields: &HashMap<String, String>,
1632 nested_types: &HashMap<String, String>,
1633) -> String {
1634 if obj.is_empty() {
1635 return format!("new {type_name}()");
1636 }
1637
1638 static JSON_ELEMENT_FIELDS: &[&str] = &["output_format"];
1641
1642 let props: Vec<String> = obj
1643 .iter()
1644 .map(|(key, val)| {
1645 let pascal_key = key.to_upper_camel_case();
1646 let cs_val = if let Some(enum_type) = enum_fields.get(key.as_str()) {
1647 let member = val
1649 .as_str()
1650 .map(|s| s.to_upper_camel_case())
1651 .unwrap_or_else(|| "null".to_string());
1652 format!("{enum_type}.{member}")
1653 } else if let Some(nested_type) = nested_types.get(key.as_str()) {
1654 let normalized = normalize_csharp_enum_values(val, enum_fields);
1656 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
1657 format!(
1658 "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
1659 escape_csharp(&json_str)
1660 )
1661 } else if let Some(arr) = val.as_array() {
1662 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1664 format!("new List<string> {{ {} }}", items.join(", "))
1665 } else if JSON_ELEMENT_FIELDS.contains(&key.as_str()) {
1666 if val.is_null() {
1668 "null".to_string()
1669 } else {
1670 let json_str = serde_json::to_string(val).unwrap_or_default();
1671 format!("JsonDocument.Parse(\"{}\").RootElement", escape_csharp(&json_str))
1672 }
1673 } else {
1674 json_to_csharp(val)
1675 };
1676 format!("{pascal_key} = {cs_val}")
1677 })
1678 .collect();
1679 format!("new {} {{ {} }}", type_name, props.join(", "))
1680}
1681
1682fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
1687 match value {
1688 serde_json::Value::Object(map) => {
1689 let mut result = map.clone();
1690 for (key, val) in result.iter_mut() {
1691 if enum_fields.contains_key(key) {
1692 if let Some(s) = val.as_str() {
1694 *val = serde_json::Value::String(s.to_lowercase());
1695 }
1696 }
1697 }
1698 serde_json::Value::Object(result)
1699 }
1700 other => other.clone(),
1701 }
1702}
1703
1704fn build_csharp_visitor(
1715 setup_lines: &mut Vec<String>,
1716 class_decls: &mut Vec<String>,
1717 fixture_id: &str,
1718 visitor_spec: &crate::fixture::VisitorSpec,
1719) -> String {
1720 use heck::ToUpperCamelCase;
1721 let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
1722 let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
1723
1724 setup_lines.push(format!("var {var_name} = new {class_name}();"));
1725
1726 let mut decl = String::new();
1728 let _ = writeln!(decl, " private sealed class {class_name} : IHtmlVisitor");
1729 let _ = writeln!(decl, " {{");
1730
1731 let all_methods = [
1733 "visit_element_start",
1734 "visit_element_end",
1735 "visit_text",
1736 "visit_link",
1737 "visit_image",
1738 "visit_heading",
1739 "visit_code_block",
1740 "visit_code_inline",
1741 "visit_list_item",
1742 "visit_list_start",
1743 "visit_list_end",
1744 "visit_table_start",
1745 "visit_table_row",
1746 "visit_table_end",
1747 "visit_blockquote",
1748 "visit_strong",
1749 "visit_emphasis",
1750 "visit_strikethrough",
1751 "visit_underline",
1752 "visit_subscript",
1753 "visit_superscript",
1754 "visit_mark",
1755 "visit_line_break",
1756 "visit_horizontal_rule",
1757 "visit_custom_element",
1758 "visit_definition_list_start",
1759 "visit_definition_term",
1760 "visit_definition_description",
1761 "visit_definition_list_end",
1762 "visit_form",
1763 "visit_input",
1764 "visit_button",
1765 "visit_audio",
1766 "visit_video",
1767 "visit_iframe",
1768 "visit_details",
1769 "visit_summary",
1770 "visit_figure_start",
1771 "visit_figcaption",
1772 "visit_figure_end",
1773 ];
1774
1775 for method_name in &all_methods {
1777 if let Some(action) = visitor_spec.callbacks.get(*method_name) {
1778 emit_csharp_visitor_method(&mut decl, method_name, action);
1779 } else {
1780 emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
1782 }
1783 }
1784
1785 let _ = writeln!(decl, " }}");
1786 class_decls.push(decl);
1787
1788 var_name
1789}
1790
1791fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
1793 let camel_method = method_to_camel(method_name);
1794 let params = match method_name {
1795 "visit_link" => "NodeContext ctx, string href, string text, string title",
1796 "visit_image" => "NodeContext ctx, string src, string alt, string title",
1797 "visit_heading" => "NodeContext ctx, uint level, string text, string id",
1798 "visit_code_block" => "NodeContext ctx, string lang, string code",
1799 "visit_code_inline"
1800 | "visit_strong"
1801 | "visit_emphasis"
1802 | "visit_strikethrough"
1803 | "visit_underline"
1804 | "visit_subscript"
1805 | "visit_superscript"
1806 | "visit_mark"
1807 | "visit_button"
1808 | "visit_summary"
1809 | "visit_figcaption"
1810 | "visit_definition_term"
1811 | "visit_definition_description" => "NodeContext ctx, string text",
1812 "visit_text" => "NodeContext ctx, string text",
1813 "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
1814 "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
1815 "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
1816 "visit_custom_element" => "NodeContext ctx, string tagName, string html",
1817 "visit_form" => "NodeContext ctx, string actionUrl, string method",
1818 "visit_input" => "NodeContext ctx, string inputType, string name, string value",
1819 "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
1820 "visit_details" => "NodeContext ctx, bool isOpen",
1821 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1822 "NodeContext ctx, string output"
1823 }
1824 "visit_list_start" => "NodeContext ctx, bool ordered",
1825 "visit_list_end" => "NodeContext ctx, bool ordered, string output",
1826 "visit_element_start"
1827 | "visit_table_start"
1828 | "visit_definition_list_start"
1829 | "visit_figure_start"
1830 | "visit_line_break"
1831 | "visit_horizontal_rule" => "NodeContext ctx",
1832 _ => "NodeContext ctx",
1833 };
1834
1835 let _ = writeln!(decl, " public VisitResult {camel_method}({params})");
1836 let _ = writeln!(decl, " {{");
1837 match action {
1838 CallbackAction::Skip => {
1839 let _ = writeln!(decl, " return new VisitResult.Skip();");
1840 }
1841 CallbackAction::Continue => {
1842 let _ = writeln!(decl, " return new VisitResult.Continue();");
1843 }
1844 CallbackAction::PreserveHtml => {
1845 let _ = writeln!(decl, " return new VisitResult.PreserveHtml();");
1846 }
1847 CallbackAction::Custom { output } => {
1848 let escaped = escape_csharp(output);
1849 let _ = writeln!(decl, " return new VisitResult.Custom(\"{escaped}\");");
1850 }
1851 CallbackAction::CustomTemplate { template } => {
1852 let camel = snake_case_template_to_camel(template);
1853 let escaped = escape_csharp(&camel);
1854 let _ = writeln!(decl, " return new VisitResult.Custom($\"{escaped}\");");
1855 }
1856 }
1857 let _ = writeln!(decl, " }}");
1858}
1859
1860fn method_to_camel(snake: &str) -> String {
1862 use heck::ToUpperCamelCase;
1863 snake.to_upper_camel_case()
1864}
1865
1866fn snake_case_template_to_camel(template: &str) -> String {
1869 use heck::ToLowerCamelCase;
1870 let mut out = String::with_capacity(template.len());
1871 let mut chars = template.chars().peekable();
1872 while let Some(c) = chars.next() {
1873 if c == '{' {
1874 let mut name = String::new();
1875 while let Some(&nc) = chars.peek() {
1876 if nc == '}' {
1877 chars.next();
1878 break;
1879 }
1880 name.push(nc);
1881 chars.next();
1882 }
1883 out.push('{');
1884 out.push_str(&name.to_lower_camel_case());
1885 out.push('}');
1886 } else {
1887 out.push(c);
1888 }
1889 }
1890 out
1891}
1892
1893fn build_csharp_method_call(
1898 result_var: &str,
1899 method_name: &str,
1900 args: Option<&serde_json::Value>,
1901 class_name: &str,
1902) -> String {
1903 match method_name {
1904 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
1905 "root_node_type" => format!("{result_var}.RootNode.Kind"),
1906 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
1907 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
1908 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
1909 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
1910 "contains_node_type" => {
1911 let node_type = args
1912 .and_then(|a| a.get("node_type"))
1913 .and_then(|v| v.as_str())
1914 .unwrap_or("");
1915 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1916 }
1917 "find_nodes_by_type" => {
1918 let node_type = args
1919 .and_then(|a| a.get("node_type"))
1920 .and_then(|v| v.as_str())
1921 .unwrap_or("");
1922 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1923 }
1924 "run_query" => {
1925 let query_source = args
1926 .and_then(|a| a.get("query_source"))
1927 .and_then(|v| v.as_str())
1928 .unwrap_or("");
1929 let language = args
1930 .and_then(|a| a.get("language"))
1931 .and_then(|v| v.as_str())
1932 .unwrap_or("");
1933 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1934 }
1935 _ => {
1936 use heck::ToUpperCamelCase;
1937 let pascal = method_name.to_upper_camel_case();
1938 format!("{result_var}.{pascal}()")
1939 }
1940 }
1941}
1942
1943fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
1944 if fixture.is_http_test() {
1946 return false;
1947 }
1948 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1949 let cs_override = call_config
1950 .overrides
1951 .get("csharp")
1952 .or_else(|| e2e_config.call.overrides.get("csharp"));
1953 if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
1955 return true;
1956 }
1957 cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
1960}