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