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 crate::template_env::render(
184 "csharp/csproj.jinja",
185 minijinja::context! {
186 pkg_ref => pkg_ref,
187 microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
188 xunit_version => tv::nuget::XUNIT,
189 xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
190 },
191 )
192}
193
194fn render_test_setup() -> String {
195 let mut out = String::new();
196 out.push_str(&hash::header(CommentStyle::DoubleSlash));
197 out.push_str(
198 r#"using System;
199using System.IO;
200using System.Runtime.CompilerServices;
201
202namespace Kreuzberg.E2eTests;
203
204internal static class TestSetup
205{
206 [ModuleInitializer]
207 internal static void Init()
208 {
209 // Walk up from the assembly directory until we find the repo root
210 // (the directory containing test_documents/) so that fixture paths
211 // like "docx/fake.docx" resolve regardless of where dotnet test
212 // launched the runner from.
213 var dir = new DirectoryInfo(AppContext.BaseDirectory);
214 while (dir != null)
215 {
216 var candidate = Path.Combine(dir.FullName, "test_documents");
217 if (Directory.Exists(candidate))
218 {
219 Directory.SetCurrentDirectory(candidate);
220 return;
221 }
222 dir = dir.Parent;
223 }
224 }
225}
226"#,
227 );
228 out
229}
230
231#[allow(clippy::too_many_arguments)]
232fn render_test_file(
233 category: &str,
234 fixtures: &[&Fixture],
235 namespace: &str,
236 class_name: &str,
237 function_name: &str,
238 exception_class: &str,
239 result_var: &str,
240 test_class: &str,
241 args: &[crate::config::ArgMapping],
242 field_resolver: &FieldResolver,
243 result_is_simple: bool,
244 is_async: bool,
245 e2e_config: &E2eConfig,
246 enum_fields: &HashMap<String, String>,
247 nested_types: &HashMap<String, String>,
248) -> String {
249 let mut using_imports = String::new();
251 using_imports.push_str("using System;\n");
252 using_imports.push_str("using System.Collections.Generic;\n");
253 using_imports.push_str("using System.Linq;\n");
254 using_imports.push_str("using System.Net.Http;\n");
255 using_imports.push_str("using System.Text;\n");
256 using_imports.push_str("using System.Text.Json;\n");
257 using_imports.push_str("using System.Text.Json.Serialization;\n");
258 using_imports.push_str("using System.Threading.Tasks;\n");
259 using_imports.push_str("using Xunit;\n");
260 using_imports.push_str(&format!("using {namespace};\n"));
261 using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));
262
263 let config_options_field = " private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
265
266 let mut visitor_class_decls: Vec<String> = Vec::new();
270
271 let mut fixtures_body = String::new();
273 for (i, fixture) in fixtures.iter().enumerate() {
274 render_test_method(
275 &mut fixtures_body,
276 &mut visitor_class_decls,
277 fixture,
278 class_name,
279 function_name,
280 exception_class,
281 result_var,
282 args,
283 field_resolver,
284 result_is_simple,
285 is_async,
286 e2e_config,
287 enum_fields,
288 nested_types,
289 );
290 if i + 1 < fixtures.len() {
291 fixtures_body.push('\n');
292 }
293 }
294
295 let mut visitor_classes_str = String::new();
297 for (i, decl) in visitor_class_decls.iter().enumerate() {
298 if i > 0 {
299 visitor_classes_str.push('\n');
300 }
301 visitor_classes_str.push('\n');
302 visitor_classes_str.push_str(decl);
303 visitor_classes_str.push('\n');
304 }
305
306 let ctx = minijinja::context! {
307 header => hash::header(CommentStyle::DoubleSlash),
308 using_imports => using_imports,
309 category => category,
310 namespace => namespace,
311 test_class => test_class,
312 config_options_field => config_options_field,
313 fixtures_body => fixtures_body,
314 visitor_class_decls => visitor_classes_str,
315 };
316
317 crate::template_env::render("csharp/test_file.jinja", ctx)
318}
319
320struct CSharpTestClientRenderer;
329
330fn to_csharp_http_method(method: &str) -> String {
332 let lower = method.to_ascii_lowercase();
333 let mut chars = lower.chars();
334 match chars.next() {
335 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
336 None => String::new(),
337 }
338}
339
340const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
344 "content-length",
345 "host",
346 "connection",
347 "expect",
348 "transfer-encoding",
349 "upgrade",
350 "content-type",
353 "content-encoding",
355 "content-language",
356 "content-location",
357 "content-md5",
358 "content-range",
359 "content-disposition",
360];
361
362fn is_csharp_content_header(name: &str) -> bool {
366 matches!(
367 name.to_ascii_lowercase().as_str(),
368 "content-type"
369 | "content-length"
370 | "content-encoding"
371 | "content-language"
372 | "content-location"
373 | "content-md5"
374 | "content-range"
375 | "content-disposition"
376 | "expires"
377 | "last-modified"
378 | "allow"
379 )
380}
381
382impl client::TestClientRenderer for CSharpTestClientRenderer {
383 fn language_name(&self) -> &'static str {
384 "csharp"
385 }
386
387 fn sanitize_test_name(&self, id: &str) -> String {
389 id.to_upper_camel_case()
390 }
391
392 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
395 let escaped_reason = skip_reason.map(escape_csharp);
396 let rendered = crate::template_env::render(
397 "csharp/http_test_open.jinja",
398 minijinja::context! {
399 fn_name => fn_name,
400 description => description,
401 skip_reason => escaped_reason,
402 },
403 );
404 out.push_str(&rendered);
405 }
406
407 fn render_test_close(&self, out: &mut String) {
409 let rendered = crate::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
410 out.push_str(&rendered);
411 }
412
413 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
418 let method = to_csharp_http_method(ctx.method);
419 let path = escape_csharp(ctx.path);
420
421 out.push_str(" var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
422 out.push_str(
425 " using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
426 );
427 out.push_str(" using var client = new System.Net.Http.HttpClient(handler);\n");
428 out.push_str(&format!(" var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));
429
430 if let Some(body) = ctx.body {
432 let content_type = ctx.content_type.unwrap_or("application/json");
433 let json_str = serde_json::to_string(body).unwrap_or_default();
434 let escaped = escape_csharp(&json_str);
435 out.push_str(&format!(" request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
436 }
437
438 for (name, value) in ctx.headers {
440 if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
441 continue;
442 }
443 let escaped_name = escape_csharp(name);
444 let escaped_value = escape_csharp(value);
445 out.push_str(&format!(
446 " request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
447 ));
448 }
449
450 if !ctx.cookies.is_empty() {
452 let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
453 pairs.sort();
454 let cookie_header = escape_csharp(&pairs.join("; "));
455 out.push_str(&format!(
456 " request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
457 ));
458 }
459
460 out.push_str(" var response = await client.SendAsync(request);\n");
461 }
462
463 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
465 out.push_str(&format!(" Assert.Equal({status}, (int)response.StatusCode);\n"));
466 }
467
468 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
473 let target = if is_csharp_content_header(name) {
474 "response.Content.Headers"
475 } else {
476 "response.Headers"
477 };
478 let escaped_name = escape_csharp(name);
479 match expected {
480 "<<present>>" => {
481 out.push_str(&format!(" Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
482 }
483 "<<absent>>" => {
484 out.push_str(&format!(" Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
485 }
486 "<<uuid>>" => {
487 out.push_str(&format!(" 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\");\n"));
489 }
490 literal => {
491 let var_name = format!("hdr{}", sanitize_ident(name));
494 let escaped_value = escape_csharp(literal);
495 out.push_str(&format!(" Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");\n"));
496 }
497 }
498 }
499
500 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
504 match expected {
505 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
506 let json_str = serde_json::to_string(expected).unwrap_or_default();
507 let escaped = escape_csharp(&json_str);
508 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
509 out.push_str(" var body = JsonDocument.Parse(bodyText).RootElement;\n");
510 out.push_str(&format!(
511 " var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
512 ));
513 out.push_str(" Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
514 }
515 serde_json::Value::String(s) => {
516 let escaped = escape_csharp(s);
517 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
518 out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
519 }
520 other => {
521 let escaped = escape_csharp(&other.to_string());
522 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
523 out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
524 }
525 }
526 }
527
528 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
533 if let Some(obj) = expected.as_object() {
534 out.push_str(" var partialBodyText = await response.Content.ReadAsStringAsync();\n");
535 out.push_str(" var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
536 for (key, val) in obj {
537 let escaped_key = escape_csharp(key);
538 let json_str = serde_json::to_string(val).unwrap_or_default();
539 let escaped_val = escape_csharp(&json_str);
540 let var_name = format!("expected{}", key.to_upper_camel_case());
541 out.push_str(&format!(
542 " var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
543 ));
544 out.push_str(&format!(" Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");\n"));
545 }
546 }
547 }
548
549 fn render_assert_validation_errors(
552 &self,
553 out: &mut String,
554 _response_var: &str,
555 errors: &[ValidationErrorExpectation],
556 ) {
557 out.push_str(" var validationBodyText = await response.Content.ReadAsStringAsync();\n");
558 for err in errors {
559 let escaped_msg = escape_csharp(&err.msg);
560 out.push_str(&format!(
561 " Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
562 ));
563 }
564 }
565}
566
567fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
570 client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
571}
572
573#[allow(clippy::too_many_arguments)]
574fn render_test_method(
575 out: &mut String,
576 visitor_class_decls: &mut Vec<String>,
577 fixture: &Fixture,
578 class_name: &str,
579 _function_name: &str,
580 exception_class: &str,
581 _result_var: &str,
582 _args: &[crate::config::ArgMapping],
583 field_resolver: &FieldResolver,
584 result_is_simple: bool,
585 _is_async: bool,
586 e2e_config: &E2eConfig,
587 enum_fields: &HashMap<String, String>,
588 nested_types: &HashMap<String, String>,
589) {
590 let method_name = fixture.id.to_upper_camel_case();
591 let description = &fixture.description;
592
593 if let Some(http) = &fixture.http {
595 render_http_test_method(out, fixture, http);
596 return;
597 }
598
599 if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
602 let skip_reason =
603 "non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
604 let ctx = minijinja::context! {
605 is_skipped => true,
606 skip_reason => skip_reason,
607 description => description,
608 method_name => method_name,
609 };
610 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
611 out.push_str(&rendered);
612 return;
613 }
614
615 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
616
617 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
620 let lang = "csharp";
621 let cs_overrides = call_config.overrides.get(lang);
622 let effective_function_name = cs_overrides
623 .and_then(|o| o.function.as_ref())
624 .cloned()
625 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
626 let effective_result_var = &call_config.result_var;
627 let effective_is_async = call_config.r#async;
628 let function_name = effective_function_name.as_str();
629 let result_var = effective_result_var.as_str();
630 let is_async = effective_is_async;
631 let args = call_config.args.as_slice();
632
633 let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
637 let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
638 let returns_void = call_config.returns_void;
639 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
640 let top_level_options_type = e2e_config
642 .call
643 .overrides
644 .get("csharp")
645 .and_then(|o| o.options_type.as_deref());
646 let effective_options_type = cs_overrides
647 .and_then(|o| o.options_type.as_deref())
648 .or(top_level_options_type);
649
650 let (mut setup_lines, args_str) = build_args_and_setup(
651 &fixture.input,
652 args,
653 class_name,
654 effective_options_type,
655 enum_fields,
656 nested_types,
657 &fixture.id,
658 );
659
660 let mut visitor_arg = String::new();
662 let has_visitor = fixture.visitor.is_some();
663 if let Some(visitor_spec) = &fixture.visitor {
664 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
665 }
666
667 let final_args = if has_visitor && !visitor_arg.is_empty() {
671 let opts_type = effective_options_type.unwrap_or("ConversionOptions");
672 if args_str.contains("JsonSerializer.Deserialize") {
673 setup_lines.push(format!("var options = {args_str};"));
675 setup_lines.push(format!("options.Visitor = {visitor_arg};"));
676 "options".to_string()
677 } else if args_str.ends_with(", null") {
678 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
680 let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
682 } else if args_str.contains(", null,") {
683 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
685 args_str.replace(", null,", ", options,")
686 } else if args_str.is_empty() {
687 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
689 "options".to_string()
690 } else {
691 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
693 format!("{args_str}, options")
694 }
695 } else if extra_args_slice.is_empty() {
696 args_str
697 } else if args_str.is_empty() {
698 extra_args_slice.join(", ")
699 } else {
700 format!("{args_str}, {}", extra_args_slice.join(", "))
701 };
702
703 let effective_function_name = function_name.to_string();
706
707 let return_type = if is_async { "async Task" } else { "void" };
708 let await_kw = if is_async { "await " } else { "" };
709
710 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
713 e2e_config
714 .call
715 .overrides
716 .get("csharp")
717 .and_then(|o| o.client_factory.as_deref())
718 });
719 let call_target = if client_factory.is_some() {
720 "client".to_string()
721 } else {
722 class_name.to_string()
723 };
724
725 let mut client_factory_setup = String::new();
727 if let Some(factory) = client_factory {
728 let factory_name = factory.to_upper_camel_case();
729 let fixture_id = &fixture.id;
730 client_factory_setup.push_str(&format!(" var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
731 client_factory_setup.push_str(&format!(
732 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
733 ));
734 }
735
736 let call_expr = format!("{}({})", effective_function_name, final_args);
738
739 let mut assertions_body = String::new();
741 if !expects_error && !returns_void {
742 for assertion in &fixture.assertions {
743 render_assertion(
744 &mut assertions_body,
745 assertion,
746 result_var,
747 class_name,
748 exception_class,
749 field_resolver,
750 effective_result_is_simple,
751 call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
752 call_config.result_is_array,
753 );
754 }
755 }
756
757 let ctx = minijinja::context! {
758 is_skipped => false,
759 expects_error => expects_error,
760 description => description,
761 return_type => return_type,
762 method_name => method_name,
763 async_kw => await_kw,
764 call_target => call_target,
765 setup_lines => setup_lines.clone(),
766 call_expr => call_expr,
767 exception_class => exception_class,
768 client_factory_setup => client_factory_setup,
769 has_usable_assertion => !expects_error && !returns_void,
770 result_var => result_var,
771 assertions_body => assertions_body,
772 };
773
774 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
775 out.push_str(&rendered);
776}
777
778fn build_args_and_setup(
782 input: &serde_json::Value,
783 args: &[crate::config::ArgMapping],
784 class_name: &str,
785 options_type: Option<&str>,
786 enum_fields: &HashMap<String, String>,
787 nested_types: &HashMap<String, String>,
788 fixture_id: &str,
789) -> (Vec<String>, String) {
790 if args.is_empty() {
791 return (Vec::new(), String::new());
792 }
793
794 let mut setup_lines: Vec<String> = Vec::new();
795 let mut parts: Vec<String> = Vec::new();
796
797 for arg in args {
798 if arg.arg_type == "bytes" {
799 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
802 let val = input.get(field);
803 match val {
804 None | Some(serde_json::Value::Null) if arg.optional => {
805 parts.push("null".to_string());
806 }
807 None | Some(serde_json::Value::Null) => {
808 parts.push("System.Array.Empty<byte>()".to_string());
809 }
810 Some(v) => {
811 let cs_str = json_to_csharp(v);
812 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
813 }
814 }
815 continue;
816 }
817
818 if arg.arg_type == "mock_url" {
819 setup_lines.push(format!(
820 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
821 arg.name,
822 ));
823 parts.push(arg.name.clone());
824 continue;
825 }
826
827 if arg.arg_type == "handle" {
828 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
830 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
831 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
832 if config_value.is_null()
833 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
834 {
835 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
836 } else {
837 let sorted = sort_discriminator_first(config_value.clone());
841 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
842 let name = &arg.name;
843 setup_lines.push(format!(
844 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
845 escape_csharp(&json_str),
846 ));
847 setup_lines.push(format!(
848 "var {} = {class_name}.{constructor_name}({name}Config);",
849 arg.name,
850 name = name,
851 ));
852 }
853 parts.push(arg.name.clone());
854 continue;
855 }
856
857 let val: Option<&serde_json::Value> = if arg.field == "input" {
860 Some(input)
861 } else {
862 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
863 input.get(field)
864 };
865 match val {
866 None | Some(serde_json::Value::Null) if arg.optional => {
867 parts.push("null".to_string());
870 continue;
871 }
872 None | Some(serde_json::Value::Null) => {
873 let default_val = match arg.arg_type.as_str() {
877 "string" => "\"\"".to_string(),
878 "int" | "integer" => "0".to_string(),
879 "float" | "number" => "0.0d".to_string(),
880 "bool" | "boolean" => "false".to_string(),
881 "json_object" => {
882 if let Some(opts_type) = options_type {
883 format!("new {opts_type}()")
884 } else {
885 "null".to_string()
886 }
887 }
888 _ => "null".to_string(),
889 };
890 parts.push(default_val);
891 }
892 Some(v) => {
893 if arg.arg_type == "json_object" {
894 if let Some(arr) = v.as_array() {
896 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
897 continue;
898 }
899 if let Some(opts_type) = options_type {
901 if let Some(obj) = v.as_object() {
902 parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
903 continue;
904 }
905 }
906 }
907 parts.push(json_to_csharp(v));
908 }
909 }
910 }
911
912 (setup_lines, parts.join(", "))
913}
914
915fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
923 match element_type {
924 Some("BatchBytesItem") => {
925 let items: Vec<String> = arr
926 .iter()
927 .filter_map(|v| v.as_object())
928 .map(|obj| {
929 let content = obj.get("content").and_then(|v| v.as_array());
930 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
931 let content_code = if let Some(arr) = content {
932 let bytes: Vec<String> = arr
933 .iter()
934 .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
935 .collect();
936 format!("new byte[] {{ {} }}", bytes.join(", "))
937 } else {
938 "new byte[] { }".to_string()
939 };
940 format!(
941 "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
942 content_code, mime_type
943 )
944 })
945 .collect();
946 format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
947 }
948 Some("BatchFileItem") => {
949 let items: Vec<String> = arr
950 .iter()
951 .filter_map(|v| v.as_object())
952 .map(|obj| {
953 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
954 format!("new BatchFileItem {{ Path = \"{}\" }}", path)
955 })
956 .collect();
957 format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
958 }
959 Some("f32") => {
960 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
961 format!("new List<float>() {{ {} }}", items.join(", "))
962 }
963 Some("(String, String)") => {
964 let items: Vec<String> = arr
965 .iter()
966 .map(|v| {
967 let strs: Vec<String> = v
968 .as_array()
969 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
970 format!("new List<string>() {{ {} }}", strs.join(", "))
971 })
972 .collect();
973 format!("new List<List<string>>() {{ {} }}", items.join(", "))
974 }
975 Some(et)
976 if et != "f32"
977 && et != "(String, String)"
978 && et != "string"
979 && et != "BatchBytesItem"
980 && et != "BatchFileItem" =>
981 {
982 let items: Vec<String> = arr
984 .iter()
985 .map(|v| {
986 let json_str = serde_json::to_string(v).unwrap_or_default();
987 let escaped = escape_csharp(&json_str);
988 format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
989 })
990 .collect();
991 format!("new List<{et}>() {{ {} }}", items.join(", "))
992 }
993 _ => {
994 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
995 format!("new List<string>() {{ {} }}", items.join(", "))
996 }
997 }
998}
999
1000fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1004 let parts: Vec<&str> = field.split('.').collect();
1005 if parts.len() >= 3 && parts.len() <= 4 {
1006 if parts[0] == "metadata" && parts[1] == "format" {
1008 let variant_name = parts[2];
1009 let known_variants = [
1011 "pdf",
1012 "docx",
1013 "excel",
1014 "email",
1015 "pptx",
1016 "archive",
1017 "image",
1018 "xml",
1019 "text",
1020 "html",
1021 "ocr",
1022 "csv",
1023 "bibtex",
1024 "citation",
1025 "fiction_book",
1026 "dbf",
1027 "jats",
1028 "epub",
1029 "pst",
1030 "code",
1031 ];
1032 if known_variants.contains(&variant_name) {
1033 let variant_pascal = variant_name.to_upper_camel_case();
1034 if parts.len() == 4 {
1035 let inner_field = parts[3];
1036 return Some((
1037 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1038 variant_pascal,
1039 inner_field.to_string(),
1040 ));
1041 } else if parts.len() == 3 {
1042 return Some((
1044 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1045 variant_pascal,
1046 String::new(),
1047 ));
1048 }
1049 }
1050 }
1051 }
1052 None
1053}
1054
1055fn render_discriminated_union_assertion(
1059 out: &mut String,
1060 assertion: &Assertion,
1061 variant_var: &str,
1062 inner_field: &str,
1063 _result_is_vec: bool,
1064) {
1065 if inner_field.is_empty() {
1066 return; }
1068
1069 let field_pascal = inner_field.to_upper_camel_case();
1070 let field_expr = format!("{variant_var}.Value.{field_pascal}");
1071
1072 match assertion.assertion_type.as_str() {
1073 "equals" => {
1074 if let Some(expected) = &assertion.value {
1075 let cs_val = json_to_csharp(expected);
1076 if expected.is_string() {
1077 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}!.Trim());");
1078 } else if expected.as_bool() == Some(true) {
1079 let _ = writeln!(out, " Assert.True({field_expr});");
1080 } else if expected.as_bool() == Some(false) {
1081 let _ = writeln!(out, " Assert.False({field_expr});");
1082 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1083 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
1084 } else {
1085 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
1086 }
1087 }
1088 }
1089 "greater_than_or_equal" => {
1090 if let Some(val) = &assertion.value {
1091 let cs_val = json_to_csharp(val);
1092 let _ = writeln!(
1093 out,
1094 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1095 );
1096 }
1097 }
1098 "contains_all" => {
1099 if let Some(values) = &assertion.values {
1100 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1101 for val in values {
1102 let lower_val = val.as_str().map(|s| s.to_lowercase());
1103 let cs_val = lower_val
1104 .as_deref()
1105 .map(|s| format!("\"{}\"", escape_csharp(s)))
1106 .unwrap_or_else(|| json_to_csharp(val));
1107 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1108 }
1109 }
1110 }
1111 "contains" => {
1112 if let Some(expected) = &assertion.value {
1113 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1114 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1115 let cs_val = lower_expected
1116 .as_deref()
1117 .map(|s| format!("\"{}\"", escape_csharp(s)))
1118 .unwrap_or_else(|| json_to_csharp(expected));
1119 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1120 }
1121 }
1122 "not_empty" => {
1123 let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
1124 }
1125 "is_empty" => {
1126 let _ = writeln!(out, " Assert.Empty({field_expr});");
1127 }
1128 _ => {
1129 let _ = writeln!(
1130 out,
1131 " // skipped: assertion type '{}' not yet supported for discriminated union fields",
1132 assertion.assertion_type
1133 );
1134 }
1135 }
1136}
1137
1138#[allow(clippy::too_many_arguments)]
1139fn render_assertion(
1140 out: &mut String,
1141 assertion: &Assertion,
1142 result_var: &str,
1143 class_name: &str,
1144 exception_class: &str,
1145 field_resolver: &FieldResolver,
1146 result_is_simple: bool,
1147 result_is_vec: bool,
1148 result_is_array: bool,
1149) {
1150 if let Some(f) = &assertion.field {
1153 match f.as_str() {
1154 "chunks_have_content" => {
1155 let synthetic_pred =
1156 format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
1157 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1158 "is_true" => "is_true",
1159 "is_false" => "is_false",
1160 _ => {
1161 out.push_str(&format!(
1162 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1163 ));
1164 return;
1165 }
1166 };
1167 let rendered = crate::template_env::render(
1168 "csharp/assertion.jinja",
1169 minijinja::context! {
1170 assertion_type => "synthetic_assertion",
1171 synthetic_pred => synthetic_pred,
1172 synthetic_pred_type => synthetic_pred_type,
1173 },
1174 );
1175 out.push_str(&rendered);
1176 return;
1177 }
1178 "chunks_have_embeddings" => {
1179 let synthetic_pred =
1180 format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
1181 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1182 "is_true" => "is_true",
1183 "is_false" => "is_false",
1184 _ => {
1185 out.push_str(&format!(
1186 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1187 ));
1188 return;
1189 }
1190 };
1191 let rendered = crate::template_env::render(
1192 "csharp/assertion.jinja",
1193 minijinja::context! {
1194 assertion_type => "synthetic_assertion",
1195 synthetic_pred => synthetic_pred,
1196 synthetic_pred_type => synthetic_pred_type,
1197 },
1198 );
1199 out.push_str(&rendered);
1200 return;
1201 }
1202 "embeddings" => {
1206 match assertion.assertion_type.as_str() {
1207 "count_equals" => {
1208 if let Some(val) = &assertion.value {
1209 if let Some(n) = val.as_u64() {
1210 let rendered = crate::template_env::render(
1211 "csharp/assertion.jinja",
1212 minijinja::context! {
1213 assertion_type => "synthetic_embeddings_count_equals",
1214 synthetic_pred => format!("{result_var}.Count"),
1215 n => n,
1216 },
1217 );
1218 out.push_str(&rendered);
1219 }
1220 }
1221 }
1222 "count_min" => {
1223 if let Some(val) = &assertion.value {
1224 if let Some(n) = val.as_u64() {
1225 let rendered = crate::template_env::render(
1226 "csharp/assertion.jinja",
1227 minijinja::context! {
1228 assertion_type => "synthetic_embeddings_count_min",
1229 synthetic_pred => format!("{result_var}.Count"),
1230 n => n,
1231 },
1232 );
1233 out.push_str(&rendered);
1234 }
1235 }
1236 }
1237 "not_empty" => {
1238 let rendered = crate::template_env::render(
1239 "csharp/assertion.jinja",
1240 minijinja::context! {
1241 assertion_type => "synthetic_embeddings_not_empty",
1242 synthetic_pred => result_var.to_string(),
1243 },
1244 );
1245 out.push_str(&rendered);
1246 }
1247 "is_empty" => {
1248 let rendered = crate::template_env::render(
1249 "csharp/assertion.jinja",
1250 minijinja::context! {
1251 assertion_type => "synthetic_embeddings_is_empty",
1252 synthetic_pred => result_var.to_string(),
1253 },
1254 );
1255 out.push_str(&rendered);
1256 }
1257 _ => {
1258 out.push_str(
1259 " // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
1260 );
1261 }
1262 }
1263 return;
1264 }
1265 "embedding_dimensions" => {
1266 let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
1267 match assertion.assertion_type.as_str() {
1268 "equals" => {
1269 if let Some(val) = &assertion.value {
1270 if let Some(n) = val.as_u64() {
1271 let rendered = crate::template_env::render(
1272 "csharp/assertion.jinja",
1273 minijinja::context! {
1274 assertion_type => "synthetic_embedding_dimensions_equals",
1275 synthetic_pred => expr,
1276 n => n,
1277 },
1278 );
1279 out.push_str(&rendered);
1280 }
1281 }
1282 }
1283 "greater_than" => {
1284 if let Some(val) = &assertion.value {
1285 if let Some(n) = val.as_u64() {
1286 let rendered = crate::template_env::render(
1287 "csharp/assertion.jinja",
1288 minijinja::context! {
1289 assertion_type => "synthetic_embedding_dimensions_greater_than",
1290 synthetic_pred => expr,
1291 n => n,
1292 },
1293 );
1294 out.push_str(&rendered);
1295 }
1296 }
1297 }
1298 _ => {
1299 out.push_str(" // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
1300 }
1301 }
1302 return;
1303 }
1304 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1305 let synthetic_pred = match f.as_str() {
1306 "embeddings_valid" => {
1307 format!("{result_var}.All(e => e.Count > 0)")
1308 }
1309 "embeddings_finite" => {
1310 format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
1311 }
1312 "embeddings_non_zero" => {
1313 format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
1314 }
1315 "embeddings_normalized" => {
1316 format!(
1317 "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
1318 )
1319 }
1320 _ => unreachable!(),
1321 };
1322 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1323 "is_true" => "is_true",
1324 "is_false" => "is_false",
1325 _ => {
1326 out.push_str(&format!(
1327 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1328 ));
1329 return;
1330 }
1331 };
1332 let rendered = crate::template_env::render(
1333 "csharp/assertion.jinja",
1334 minijinja::context! {
1335 assertion_type => "synthetic_assertion",
1336 synthetic_pred => synthetic_pred,
1337 synthetic_pred_type => synthetic_pred_type,
1338 },
1339 );
1340 out.push_str(&rendered);
1341 return;
1342 }
1343 "keywords" | "keywords_count" => {
1346 let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
1347 let rendered = crate::template_env::render(
1348 "csharp/assertion.jinja",
1349 minijinja::context! {
1350 skipped_reason => skipped_reason,
1351 },
1352 );
1353 out.push_str(&rendered);
1354 return;
1355 }
1356 _ => {}
1357 }
1358 }
1359
1360 if let Some(f) = &assertion.field {
1362 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1363 let skipped_reason = format!("field '{f}' not available on result type");
1364 let rendered = crate::template_env::render(
1365 "csharp/assertion.jinja",
1366 minijinja::context! {
1367 skipped_reason => skipped_reason,
1368 },
1369 );
1370 out.push_str(&rendered);
1371 return;
1372 }
1373 }
1374
1375 let is_count_assertion = matches!(
1378 assertion.assertion_type.as_str(),
1379 "count_equals" | "count_min" | "count_max"
1380 );
1381 let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
1382 let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
1383
1384 let effective_result_var: String = if result_is_vec && !use_list_directly {
1385 format!("{result_var}[0]")
1386 } else {
1387 result_var.to_string()
1388 };
1389
1390 let is_discriminated_union = assertion
1392 .field
1393 .as_ref()
1394 .is_some_and(|f| parse_discriminated_union_access(f).is_some());
1395
1396 if is_discriminated_union {
1398 if let Some((_, variant_name, inner_field)) = assertion
1399 .field
1400 .as_ref()
1401 .and_then(|f| parse_discriminated_union_access(f))
1402 {
1403 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1405 inner_field.hash(&mut hasher);
1406 let var_hash = format!("{:x}", hasher.finish());
1407 let variant_var = format!("variant_{}", &var_hash[..8]);
1408 let _ = writeln!(
1409 out,
1410 " if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
1411 variant_name, &variant_var
1412 );
1413 let _ = writeln!(out, " {{");
1414 render_discriminated_union_assertion(out, assertion, &variant_var, &inner_field, result_is_vec);
1415 let _ = writeln!(out, " }}");
1416 let _ = writeln!(out, " else");
1417 let _ = writeln!(out, " {{");
1418 let _ = writeln!(
1419 out,
1420 " Assert.Fail(\"Expected {} format metadata\");",
1421 variant_name.to_lowercase()
1422 );
1423 let _ = writeln!(out, " }}");
1424 return;
1425 }
1426 }
1427
1428 let field_expr = if result_is_simple {
1429 effective_result_var.clone()
1430 } else {
1431 match &assertion.field {
1432 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
1433 _ => effective_result_var.clone(),
1434 }
1435 };
1436
1437 let field_needs_json_serialize = if result_is_simple {
1441 result_is_array
1444 } else {
1445 match &assertion.field {
1446 Some(f) if !f.is_empty() => field_resolver.is_array(f),
1447 _ => !result_is_simple,
1449 }
1450 };
1451 let field_as_str = if field_needs_json_serialize {
1453 format!("JsonSerializer.Serialize({field_expr})")
1454 } else {
1455 format!("{field_expr}.ToString()")
1456 };
1457
1458 match assertion.assertion_type.as_str() {
1459 "equals" => {
1460 if let Some(expected) = &assertion.value {
1461 let cs_val = json_to_csharp(expected);
1462 let is_string_val = expected.is_string();
1463 let is_bool_true = expected.as_bool() == Some(true);
1464 let is_bool_false = expected.as_bool() == Some(false);
1465 let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
1466
1467 let rendered = crate::template_env::render(
1468 "csharp/assertion.jinja",
1469 minijinja::context! {
1470 assertion_type => "equals",
1471 field_expr => field_expr.clone(),
1472 cs_val => cs_val,
1473 is_string_val => is_string_val,
1474 is_bool_true => is_bool_true,
1475 is_bool_false => is_bool_false,
1476 is_integer_val => is_integer_val,
1477 },
1478 );
1479 out.push_str(&rendered);
1480 }
1481 }
1482 "contains" => {
1483 if let Some(expected) = &assertion.value {
1484 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1491 let cs_val = lower_expected
1492 .as_deref()
1493 .map(|s| format!("\"{}\"", escape_csharp(s)))
1494 .unwrap_or_else(|| json_to_csharp(expected));
1495
1496 let rendered = crate::template_env::render(
1497 "csharp/assertion.jinja",
1498 minijinja::context! {
1499 assertion_type => "contains",
1500 field_as_str => field_as_str.clone(),
1501 cs_val => cs_val,
1502 },
1503 );
1504 out.push_str(&rendered);
1505 }
1506 }
1507 "contains_all" => {
1508 if let Some(values) = &assertion.values {
1509 let values_cs_lower: Vec<String> = values
1510 .iter()
1511 .map(|val| {
1512 let lower_val = val.as_str().map(|s| s.to_lowercase());
1513 lower_val
1514 .as_deref()
1515 .map(|s| format!("\"{}\"", escape_csharp(s)))
1516 .unwrap_or_else(|| json_to_csharp(val))
1517 })
1518 .collect();
1519
1520 let rendered = crate::template_env::render(
1521 "csharp/assertion.jinja",
1522 minijinja::context! {
1523 assertion_type => "contains_all",
1524 field_as_str => field_as_str.clone(),
1525 values_cs_lower => values_cs_lower,
1526 },
1527 );
1528 out.push_str(&rendered);
1529 }
1530 }
1531 "not_contains" => {
1532 if let Some(expected) = &assertion.value {
1533 let cs_val = json_to_csharp(expected);
1534
1535 let rendered = crate::template_env::render(
1536 "csharp/assertion.jinja",
1537 minijinja::context! {
1538 assertion_type => "not_contains",
1539 field_as_str => field_as_str.clone(),
1540 cs_val => cs_val,
1541 },
1542 );
1543 out.push_str(&rendered);
1544 }
1545 }
1546 "not_empty" => {
1547 let rendered = crate::template_env::render(
1548 "csharp/assertion.jinja",
1549 minijinja::context! {
1550 assertion_type => "not_empty",
1551 field_expr => field_expr.clone(),
1552 field_needs_json_serialize => field_needs_json_serialize,
1553 },
1554 );
1555 out.push_str(&rendered);
1556 }
1557 "is_empty" => {
1558 let rendered = crate::template_env::render(
1559 "csharp/assertion.jinja",
1560 minijinja::context! {
1561 assertion_type => "is_empty",
1562 field_expr => field_expr.clone(),
1563 field_needs_json_serialize => field_needs_json_serialize,
1564 },
1565 );
1566 out.push_str(&rendered);
1567 }
1568 "contains_any" => {
1569 if let Some(values) = &assertion.values {
1570 let checks: Vec<String> = values
1571 .iter()
1572 .map(|v| {
1573 let cs_val = json_to_csharp(v);
1574 format!("{field_as_str}.Contains({cs_val})")
1575 })
1576 .collect();
1577 let contains_any_expr = checks.join(" || ");
1578
1579 let rendered = crate::template_env::render(
1580 "csharp/assertion.jinja",
1581 minijinja::context! {
1582 assertion_type => "contains_any",
1583 contains_any_expr => contains_any_expr,
1584 },
1585 );
1586 out.push_str(&rendered);
1587 }
1588 }
1589 "greater_than" => {
1590 if let Some(val) = &assertion.value {
1591 let cs_val = json_to_csharp(val);
1592
1593 let rendered = crate::template_env::render(
1594 "csharp/assertion.jinja",
1595 minijinja::context! {
1596 assertion_type => "greater_than",
1597 field_expr => field_expr.clone(),
1598 cs_val => cs_val,
1599 },
1600 );
1601 out.push_str(&rendered);
1602 }
1603 }
1604 "less_than" => {
1605 if let Some(val) = &assertion.value {
1606 let cs_val = json_to_csharp(val);
1607
1608 let rendered = crate::template_env::render(
1609 "csharp/assertion.jinja",
1610 minijinja::context! {
1611 assertion_type => "less_than",
1612 field_expr => field_expr.clone(),
1613 cs_val => cs_val,
1614 },
1615 );
1616 out.push_str(&rendered);
1617 }
1618 }
1619 "greater_than_or_equal" => {
1620 if let Some(val) = &assertion.value {
1621 let cs_val = json_to_csharp(val);
1622
1623 let rendered = crate::template_env::render(
1624 "csharp/assertion.jinja",
1625 minijinja::context! {
1626 assertion_type => "greater_than_or_equal",
1627 field_expr => field_expr.clone(),
1628 cs_val => cs_val,
1629 },
1630 );
1631 out.push_str(&rendered);
1632 }
1633 }
1634 "less_than_or_equal" => {
1635 if let Some(val) = &assertion.value {
1636 let cs_val = json_to_csharp(val);
1637
1638 let rendered = crate::template_env::render(
1639 "csharp/assertion.jinja",
1640 minijinja::context! {
1641 assertion_type => "less_than_or_equal",
1642 field_expr => field_expr.clone(),
1643 cs_val => cs_val,
1644 },
1645 );
1646 out.push_str(&rendered);
1647 }
1648 }
1649 "starts_with" => {
1650 if let Some(expected) = &assertion.value {
1651 let cs_val = json_to_csharp(expected);
1652
1653 let rendered = crate::template_env::render(
1654 "csharp/assertion.jinja",
1655 minijinja::context! {
1656 assertion_type => "starts_with",
1657 field_expr => field_expr.clone(),
1658 cs_val => cs_val,
1659 },
1660 );
1661 out.push_str(&rendered);
1662 }
1663 }
1664 "ends_with" => {
1665 if let Some(expected) = &assertion.value {
1666 let cs_val = json_to_csharp(expected);
1667
1668 let rendered = crate::template_env::render(
1669 "csharp/assertion.jinja",
1670 minijinja::context! {
1671 assertion_type => "ends_with",
1672 field_expr => field_expr.clone(),
1673 cs_val => cs_val,
1674 },
1675 );
1676 out.push_str(&rendered);
1677 }
1678 }
1679 "min_length" => {
1680 if let Some(val) = &assertion.value {
1681 if let Some(n) = val.as_u64() {
1682 let rendered = crate::template_env::render(
1683 "csharp/assertion.jinja",
1684 minijinja::context! {
1685 assertion_type => "min_length",
1686 field_expr => field_expr.clone(),
1687 n => n,
1688 },
1689 );
1690 out.push_str(&rendered);
1691 }
1692 }
1693 }
1694 "max_length" => {
1695 if let Some(val) = &assertion.value {
1696 if let Some(n) = val.as_u64() {
1697 let rendered = crate::template_env::render(
1698 "csharp/assertion.jinja",
1699 minijinja::context! {
1700 assertion_type => "max_length",
1701 field_expr => field_expr.clone(),
1702 n => n,
1703 },
1704 );
1705 out.push_str(&rendered);
1706 }
1707 }
1708 }
1709 "count_min" => {
1710 if let Some(val) = &assertion.value {
1711 if let Some(n) = val.as_u64() {
1712 let rendered = crate::template_env::render(
1713 "csharp/assertion.jinja",
1714 minijinja::context! {
1715 assertion_type => "count_min",
1716 field_expr => field_expr.clone(),
1717 n => n,
1718 },
1719 );
1720 out.push_str(&rendered);
1721 }
1722 }
1723 }
1724 "count_equals" => {
1725 if let Some(val) = &assertion.value {
1726 if let Some(n) = val.as_u64() {
1727 let rendered = crate::template_env::render(
1728 "csharp/assertion.jinja",
1729 minijinja::context! {
1730 assertion_type => "count_equals",
1731 field_expr => field_expr.clone(),
1732 n => n,
1733 },
1734 );
1735 out.push_str(&rendered);
1736 }
1737 }
1738 }
1739 "is_true" => {
1740 let rendered = crate::template_env::render(
1741 "csharp/assertion.jinja",
1742 minijinja::context! {
1743 assertion_type => "is_true",
1744 field_expr => field_expr.clone(),
1745 },
1746 );
1747 out.push_str(&rendered);
1748 }
1749 "is_false" => {
1750 let rendered = crate::template_env::render(
1751 "csharp/assertion.jinja",
1752 minijinja::context! {
1753 assertion_type => "is_false",
1754 field_expr => field_expr.clone(),
1755 },
1756 );
1757 out.push_str(&rendered);
1758 }
1759 "not_error" => {
1760 let rendered = crate::template_env::render(
1762 "csharp/assertion.jinja",
1763 minijinja::context! {
1764 assertion_type => "not_error",
1765 },
1766 );
1767 out.push_str(&rendered);
1768 }
1769 "error" => {
1770 let rendered = crate::template_env::render(
1772 "csharp/assertion.jinja",
1773 minijinja::context! {
1774 assertion_type => "error",
1775 },
1776 );
1777 out.push_str(&rendered);
1778 }
1779 "method_result" => {
1780 if let Some(method_name) = &assertion.method {
1781 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1782 let check = assertion.check.as_deref().unwrap_or("is_true");
1783
1784 match check {
1785 "equals" => {
1786 if let Some(val) = &assertion.value {
1787 let is_check_bool_true = val.as_bool() == Some(true);
1788 let is_check_bool_false = val.as_bool() == Some(false);
1789 let cs_check_val = json_to_csharp(val);
1790
1791 let rendered = crate::template_env::render(
1792 "csharp/assertion.jinja",
1793 minijinja::context! {
1794 assertion_type => "method_result",
1795 check => "equals",
1796 call_expr => call_expr.clone(),
1797 is_check_bool_true => is_check_bool_true,
1798 is_check_bool_false => is_check_bool_false,
1799 cs_check_val => cs_check_val,
1800 },
1801 );
1802 out.push_str(&rendered);
1803 }
1804 }
1805 "is_true" => {
1806 let rendered = crate::template_env::render(
1807 "csharp/assertion.jinja",
1808 minijinja::context! {
1809 assertion_type => "method_result",
1810 check => "is_true",
1811 call_expr => call_expr.clone(),
1812 },
1813 );
1814 out.push_str(&rendered);
1815 }
1816 "is_false" => {
1817 let rendered = crate::template_env::render(
1818 "csharp/assertion.jinja",
1819 minijinja::context! {
1820 assertion_type => "method_result",
1821 check => "is_false",
1822 call_expr => call_expr.clone(),
1823 },
1824 );
1825 out.push_str(&rendered);
1826 }
1827 "greater_than_or_equal" => {
1828 if let Some(val) = &assertion.value {
1829 let check_n = val.as_u64().unwrap_or(0);
1830
1831 let rendered = crate::template_env::render(
1832 "csharp/assertion.jinja",
1833 minijinja::context! {
1834 assertion_type => "method_result",
1835 check => "greater_than_or_equal",
1836 call_expr => call_expr.clone(),
1837 check_n => check_n,
1838 },
1839 );
1840 out.push_str(&rendered);
1841 }
1842 }
1843 "count_min" => {
1844 if let Some(val) = &assertion.value {
1845 let check_n = val.as_u64().unwrap_or(0);
1846
1847 let rendered = crate::template_env::render(
1848 "csharp/assertion.jinja",
1849 minijinja::context! {
1850 assertion_type => "method_result",
1851 check => "count_min",
1852 call_expr => call_expr.clone(),
1853 check_n => check_n,
1854 },
1855 );
1856 out.push_str(&rendered);
1857 }
1858 }
1859 "is_error" => {
1860 let rendered = crate::template_env::render(
1861 "csharp/assertion.jinja",
1862 minijinja::context! {
1863 assertion_type => "method_result",
1864 check => "is_error",
1865 call_expr => call_expr.clone(),
1866 exception_class => exception_class,
1867 },
1868 );
1869 out.push_str(&rendered);
1870 }
1871 "contains" => {
1872 if let Some(val) = &assertion.value {
1873 let cs_check_val = json_to_csharp(val);
1874
1875 let rendered = crate::template_env::render(
1876 "csharp/assertion.jinja",
1877 minijinja::context! {
1878 assertion_type => "method_result",
1879 check => "contains",
1880 call_expr => call_expr.clone(),
1881 cs_check_val => cs_check_val,
1882 },
1883 );
1884 out.push_str(&rendered);
1885 }
1886 }
1887 other_check => {
1888 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1889 }
1890 }
1891 } else {
1892 panic!("C# e2e generator: method_result assertion missing 'method' field");
1893 }
1894 }
1895 "matches_regex" => {
1896 if let Some(expected) = &assertion.value {
1897 let cs_val = json_to_csharp(expected);
1898
1899 let rendered = crate::template_env::render(
1900 "csharp/assertion.jinja",
1901 minijinja::context! {
1902 assertion_type => "matches_regex",
1903 field_expr => field_expr.clone(),
1904 cs_val => cs_val,
1905 },
1906 );
1907 out.push_str(&rendered);
1908 }
1909 }
1910 other => {
1911 panic!("C# e2e generator: unsupported assertion type: {other}");
1912 }
1913 }
1914}
1915
1916fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1923 match value {
1924 serde_json::Value::Object(map) => {
1925 let mut sorted = serde_json::Map::with_capacity(map.len());
1926 if let Some(type_val) = map.get("type") {
1928 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1929 }
1930 for (k, v) in map {
1931 if k != "type" {
1932 sorted.insert(k, sort_discriminator_first(v));
1933 }
1934 }
1935 serde_json::Value::Object(sorted)
1936 }
1937 serde_json::Value::Array(arr) => {
1938 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1939 }
1940 other => other,
1941 }
1942}
1943
1944fn json_to_csharp(value: &serde_json::Value) -> String {
1946 match value {
1947 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1948 serde_json::Value::Bool(true) => "true".to_string(),
1949 serde_json::Value::Bool(false) => "false".to_string(),
1950 serde_json::Value::Number(n) => {
1951 if n.is_f64() {
1952 format!("{}d", n)
1953 } else {
1954 n.to_string()
1955 }
1956 }
1957 serde_json::Value::Null => "null".to_string(),
1958 serde_json::Value::Array(arr) => {
1959 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1960 format!("new[] {{ {} }}", items.join(", "))
1961 }
1962 serde_json::Value::Object(_) => {
1963 let json_str = serde_json::to_string(value).unwrap_or_default();
1964 format!("\"{}\"", escape_csharp(&json_str))
1965 }
1966 }
1967}
1968
1969fn default_csharp_nested_types() -> HashMap<String, String> {
1976 [
1977 ("chunking", "ChunkingConfig"),
1978 ("ocr", "OcrConfig"),
1979 ("images", "ImageExtractionConfig"),
1980 ("html_output", "HtmlOutputConfig"),
1981 ("language_detection", "LanguageDetectionConfig"),
1982 ("postprocessor", "PostProcessorConfig"),
1983 ("acceleration", "AccelerationConfig"),
1984 ("email", "EmailConfig"),
1985 ("pages", "PageConfig"),
1986 ("pdf_options", "PdfConfig"),
1987 ("layout", "LayoutDetectionConfig"),
1988 ("tree_sitter", "TreeSitterConfig"),
1989 ("structured_extraction", "StructuredExtractionConfig"),
1990 ("content_filter", "ContentFilterConfig"),
1991 ("token_reduction", "TokenReductionOptions"),
1992 ("security_limits", "SecurityLimits"),
1993 ("format", "FormatMetadata"),
1994 ]
1995 .iter()
1996 .map(|(k, v)| (k.to_string(), v.to_string()))
1997 .collect()
1998}
1999
2000fn csharp_object_initializer(
2008 obj: &serde_json::Map<String, serde_json::Value>,
2009 type_name: &str,
2010 enum_fields: &HashMap<String, String>,
2011 nested_types: &HashMap<String, String>,
2012) -> String {
2013 if obj.is_empty() {
2014 return format!("new {type_name}()");
2015 }
2016
2017 static JSON_ELEMENT_FIELDS: &[&str] = &["output_format"];
2020
2021 let props: Vec<String> = obj
2022 .iter()
2023 .map(|(key, val)| {
2024 let pascal_key = key.to_upper_camel_case();
2025 let cs_val = if let Some(enum_type) = enum_fields.get(key.as_str()) {
2026 let member = val
2028 .as_str()
2029 .map(|s| s.to_upper_camel_case())
2030 .unwrap_or_else(|| "null".to_string());
2031 format!("{enum_type}.{member}")
2032 } else if let Some(nested_type) = nested_types.get(key.as_str()) {
2033 let normalized = normalize_csharp_enum_values(val, enum_fields);
2035 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
2036 format!(
2037 "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
2038 escape_csharp(&json_str)
2039 )
2040 } else if let Some(arr) = val.as_array() {
2041 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2043 format!("new List<string> {{ {} }}", items.join(", "))
2044 } else if JSON_ELEMENT_FIELDS.contains(&key.as_str()) {
2045 if val.is_null() {
2047 "null".to_string()
2048 } else {
2049 let json_str = serde_json::to_string(val).unwrap_or_default();
2050 format!("JsonDocument.Parse(\"{}\").RootElement", escape_csharp(&json_str))
2051 }
2052 } else {
2053 json_to_csharp(val)
2054 };
2055 format!("{pascal_key} = {cs_val}")
2056 })
2057 .collect();
2058 format!("new {} {{ {} }}", type_name, props.join(", "))
2059}
2060
2061fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
2066 match value {
2067 serde_json::Value::Object(map) => {
2068 let mut result = map.clone();
2069 for (key, val) in result.iter_mut() {
2070 if enum_fields.contains_key(key) {
2071 if let Some(s) = val.as_str() {
2073 *val = serde_json::Value::String(s.to_lowercase());
2074 }
2075 }
2076 }
2077 serde_json::Value::Object(result)
2078 }
2079 other => other.clone(),
2080 }
2081}
2082
2083fn build_csharp_visitor(
2094 setup_lines: &mut Vec<String>,
2095 class_decls: &mut Vec<String>,
2096 fixture_id: &str,
2097 visitor_spec: &crate::fixture::VisitorSpec,
2098) -> String {
2099 use heck::ToUpperCamelCase;
2100 let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
2101 let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
2102
2103 setup_lines.push(format!("var {var_name} = new {class_name}();"));
2104
2105 let mut decl = String::new();
2107 decl.push_str(&format!(" private sealed class {class_name} : IHtmlVisitor\n"));
2108 decl.push_str(" {\n");
2109
2110 let all_methods = [
2112 "visit_element_start",
2113 "visit_element_end",
2114 "visit_text",
2115 "visit_link",
2116 "visit_image",
2117 "visit_heading",
2118 "visit_code_block",
2119 "visit_code_inline",
2120 "visit_list_item",
2121 "visit_list_start",
2122 "visit_list_end",
2123 "visit_table_start",
2124 "visit_table_row",
2125 "visit_table_end",
2126 "visit_blockquote",
2127 "visit_strong",
2128 "visit_emphasis",
2129 "visit_strikethrough",
2130 "visit_underline",
2131 "visit_subscript",
2132 "visit_superscript",
2133 "visit_mark",
2134 "visit_line_break",
2135 "visit_horizontal_rule",
2136 "visit_custom_element",
2137 "visit_definition_list_start",
2138 "visit_definition_term",
2139 "visit_definition_description",
2140 "visit_definition_list_end",
2141 "visit_form",
2142 "visit_input",
2143 "visit_button",
2144 "visit_audio",
2145 "visit_video",
2146 "visit_iframe",
2147 "visit_details",
2148 "visit_summary",
2149 "visit_figure_start",
2150 "visit_figcaption",
2151 "visit_figure_end",
2152 ];
2153
2154 for method_name in &all_methods {
2156 if let Some(action) = visitor_spec.callbacks.get(*method_name) {
2157 emit_csharp_visitor_method(&mut decl, method_name, action);
2158 } else {
2159 emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
2161 }
2162 }
2163
2164 decl.push_str(" }\n");
2165 class_decls.push(decl);
2166
2167 var_name
2168}
2169
2170fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
2172 let camel_method = method_to_camel(method_name);
2173 let params = match method_name {
2174 "visit_link" => "NodeContext ctx, string href, string text, string title",
2175 "visit_image" => "NodeContext ctx, string src, string alt, string title",
2176 "visit_heading" => "NodeContext ctx, uint level, string text, string id",
2177 "visit_code_block" => "NodeContext ctx, string lang, string code",
2178 "visit_code_inline"
2179 | "visit_strong"
2180 | "visit_emphasis"
2181 | "visit_strikethrough"
2182 | "visit_underline"
2183 | "visit_subscript"
2184 | "visit_superscript"
2185 | "visit_mark"
2186 | "visit_button"
2187 | "visit_summary"
2188 | "visit_figcaption"
2189 | "visit_definition_term"
2190 | "visit_definition_description" => "NodeContext ctx, string text",
2191 "visit_text" => "NodeContext ctx, string text",
2192 "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
2193 "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
2194 "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
2195 "visit_custom_element" => "NodeContext ctx, string tagName, string html",
2196 "visit_form" => "NodeContext ctx, string actionUrl, string method",
2197 "visit_input" => "NodeContext ctx, string inputType, string name, string value",
2198 "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
2199 "visit_details" => "NodeContext ctx, bool isOpen",
2200 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2201 "NodeContext ctx, string output"
2202 }
2203 "visit_list_start" => "NodeContext ctx, bool ordered",
2204 "visit_list_end" => "NodeContext ctx, bool ordered, string output",
2205 "visit_element_start"
2206 | "visit_table_start"
2207 | "visit_definition_list_start"
2208 | "visit_figure_start"
2209 | "visit_line_break"
2210 | "visit_horizontal_rule" => "NodeContext ctx",
2211 _ => "NodeContext ctx",
2212 };
2213
2214 let (action_type, action_value) = match action {
2215 CallbackAction::Skip => ("skip", String::new()),
2216 CallbackAction::Continue => ("continue", String::new()),
2217 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
2218 CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
2219 CallbackAction::CustomTemplate { template } => {
2220 let camel = snake_case_template_to_camel(template);
2221 ("custom_template", escape_csharp(&camel))
2222 }
2223 };
2224
2225 let rendered = crate::template_env::render(
2226 "csharp/visitor_method.jinja",
2227 minijinja::context! {
2228 camel_method => camel_method,
2229 params => params,
2230 action_type => action_type,
2231 action_value => action_value,
2232 },
2233 );
2234 let _ = write!(decl, "{}", rendered);
2235}
2236
2237fn method_to_camel(snake: &str) -> String {
2239 use heck::ToUpperCamelCase;
2240 snake.to_upper_camel_case()
2241}
2242
2243fn snake_case_template_to_camel(template: &str) -> String {
2246 use heck::ToLowerCamelCase;
2247 let mut out = String::with_capacity(template.len());
2248 let mut chars = template.chars().peekable();
2249 while let Some(c) = chars.next() {
2250 if c == '{' {
2251 let mut name = String::new();
2252 while let Some(&nc) = chars.peek() {
2253 if nc == '}' {
2254 chars.next();
2255 break;
2256 }
2257 name.push(nc);
2258 chars.next();
2259 }
2260 out.push('{');
2261 out.push_str(&name.to_lower_camel_case());
2262 out.push('}');
2263 } else {
2264 out.push(c);
2265 }
2266 }
2267 out
2268}
2269
2270fn build_csharp_method_call(
2275 result_var: &str,
2276 method_name: &str,
2277 args: Option<&serde_json::Value>,
2278 class_name: &str,
2279) -> String {
2280 match method_name {
2281 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
2282 "root_node_type" => format!("{result_var}.RootNode.Kind"),
2283 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
2284 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
2285 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
2286 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
2287 "contains_node_type" => {
2288 let node_type = args
2289 .and_then(|a| a.get("node_type"))
2290 .and_then(|v| v.as_str())
2291 .unwrap_or("");
2292 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
2293 }
2294 "find_nodes_by_type" => {
2295 let node_type = args
2296 .and_then(|a| a.get("node_type"))
2297 .and_then(|v| v.as_str())
2298 .unwrap_or("");
2299 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
2300 }
2301 "run_query" => {
2302 let query_source = args
2303 .and_then(|a| a.get("query_source"))
2304 .and_then(|v| v.as_str())
2305 .unwrap_or("");
2306 let language = args
2307 .and_then(|a| a.get("language"))
2308 .and_then(|v| v.as_str())
2309 .unwrap_or("");
2310 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
2311 }
2312 _ => {
2313 use heck::ToUpperCamelCase;
2314 let pascal = method_name.to_upper_camel_case();
2315 format!("{result_var}.{pascal}()")
2316 }
2317 }
2318}
2319
2320fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
2321 if fixture.is_http_test() {
2323 return false;
2324 }
2325 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
2326 let cs_override = call_config
2327 .overrides
2328 .get("csharp")
2329 .or_else(|| e2e_config.call.overrides.get("csharp"));
2330 if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
2332 return true;
2333 }
2334 cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
2337}