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