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