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
627 let raw_function_name = cs_overrides
632 .and_then(|o| o.function.as_ref())
633 .cloned()
634 .unwrap_or_else(|| call_config.function.clone());
635 if raw_function_name == "chat_stream" {
636 render_chat_stream_test_method(
637 out,
638 fixture,
639 class_name,
640 call_config,
641 cs_overrides,
642 e2e_config,
643 enum_fields,
644 nested_types,
645 exception_class,
646 );
647 return;
648 }
649
650 let effective_function_name = cs_overrides
651 .and_then(|o| o.function.as_ref())
652 .cloned()
653 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
654 let effective_result_var = &call_config.result_var;
655 let effective_is_async = call_config.r#async;
656 let function_name = effective_function_name.as_str();
657 let result_var = effective_result_var.as_str();
658 let is_async = effective_is_async;
659 let args = call_config.args.as_slice();
660
661 let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
665 let per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
670 let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
671 let effective_result_is_bytes = per_call_result_is_bytes;
672 let returns_void = call_config.returns_void;
673 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
674 let top_level_options_type = e2e_config
676 .call
677 .overrides
678 .get("csharp")
679 .and_then(|o| o.options_type.as_deref());
680 let effective_options_type = cs_overrides
681 .and_then(|o| o.options_type.as_deref())
682 .or(top_level_options_type);
683
684 let top_level_options_via = e2e_config
691 .call
692 .overrides
693 .get("csharp")
694 .and_then(|o| o.options_via.as_deref());
695 let effective_options_via = cs_overrides
696 .and_then(|o| o.options_via.as_deref())
697 .or(top_level_options_via);
698
699 let (mut setup_lines, args_str) = build_args_and_setup(
700 &fixture.input,
701 args,
702 class_name,
703 effective_options_type,
704 effective_options_via,
705 enum_fields,
706 nested_types,
707 &fixture.id,
708 );
709
710 let mut visitor_arg = String::new();
712 let has_visitor = fixture.visitor.is_some();
713 if let Some(visitor_spec) = &fixture.visitor {
714 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
715 }
716
717 let final_args = if has_visitor && !visitor_arg.is_empty() {
721 let opts_type = effective_options_type.unwrap_or("ConversionOptions");
722 if args_str.contains("JsonSerializer.Deserialize") {
723 setup_lines.push(format!("var options = {args_str};"));
725 setup_lines.push(format!("options.Visitor = {visitor_arg};"));
726 "options".to_string()
727 } else if args_str.ends_with(", null") {
728 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
730 let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
732 } else if args_str.contains(", null,") {
733 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
735 args_str.replace(", null,", ", options,")
736 } else if args_str.is_empty() {
737 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
739 "options".to_string()
740 } else {
741 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
743 format!("{args_str}, options")
744 }
745 } else if extra_args_slice.is_empty() {
746 args_str
747 } else if args_str.is_empty() {
748 extra_args_slice.join(", ")
749 } else {
750 format!("{args_str}, {}", extra_args_slice.join(", "))
751 };
752
753 let effective_function_name = function_name.to_string();
756
757 let return_type = if is_async { "async Task" } else { "void" };
758 let await_kw = if is_async { "await " } else { "" };
759
760 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
763 e2e_config
764 .call
765 .overrides
766 .get("csharp")
767 .and_then(|o| o.client_factory.as_deref())
768 });
769 let call_target = if client_factory.is_some() {
770 "client".to_string()
771 } else {
772 class_name.to_string()
773 };
774
775 let mut client_factory_setup = String::new();
777 if let Some(factory) = client_factory {
778 let factory_name = factory.to_upper_camel_case();
779 let fixture_id = &fixture.id;
780 client_factory_setup.push_str(&format!(" var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
781 client_factory_setup.push_str(&format!(
782 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
783 ));
784 }
785
786 let call_expr = format!("{}({})", effective_function_name, final_args);
788
789 let mut assertions_body = String::new();
791 if !expects_error && !returns_void {
792 for assertion in &fixture.assertions {
793 render_assertion(
794 &mut assertions_body,
795 assertion,
796 result_var,
797 class_name,
798 exception_class,
799 field_resolver,
800 effective_result_is_simple,
801 call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
802 call_config.result_is_array,
803 effective_result_is_bytes,
804 &e2e_config.fields_enum,
805 );
806 }
807 }
808
809 let ctx = minijinja::context! {
810 is_skipped => false,
811 expects_error => expects_error,
812 description => description,
813 return_type => return_type,
814 method_name => method_name,
815 async_kw => await_kw,
816 call_target => call_target,
817 setup_lines => setup_lines.clone(),
818 call_expr => call_expr,
819 exception_class => exception_class,
820 client_factory_setup => client_factory_setup,
821 has_usable_assertion => !expects_error && !returns_void,
822 result_var => result_var,
823 assertions_body => assertions_body,
824 };
825
826 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
827 for line in rendered.lines() {
829 out.push_str(" ");
830 out.push_str(line);
831 out.push('\n');
832 }
833}
834
835#[allow(clippy::too_many_arguments)]
843fn render_chat_stream_test_method(
844 out: &mut String,
845 fixture: &Fixture,
846 class_name: &str,
847 call_config: &crate::config::CallConfig,
848 cs_overrides: Option<&crate::config::CallOverride>,
849 e2e_config: &E2eConfig,
850 enum_fields: &HashMap<String, String>,
851 nested_types: &HashMap<String, String>,
852 exception_class: &str,
853) {
854 let method_name = fixture.id.to_upper_camel_case();
855 let description = &fixture.description;
856 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
857
858 let effective_function_name = cs_overrides
859 .and_then(|o| o.function.as_ref())
860 .cloned()
861 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
862 let function_name = effective_function_name.as_str();
863 let args = call_config.args.as_slice();
864
865 let top_level_options_type = e2e_config
866 .call
867 .overrides
868 .get("csharp")
869 .and_then(|o| o.options_type.as_deref());
870 let effective_options_type = cs_overrides
871 .and_then(|o| o.options_type.as_deref())
872 .or(top_level_options_type);
873 let top_level_options_via = e2e_config
874 .call
875 .overrides
876 .get("csharp")
877 .and_then(|o| o.options_via.as_deref());
878 let effective_options_via = cs_overrides
879 .and_then(|o| o.options_via.as_deref())
880 .or(top_level_options_via);
881
882 let (setup_lines, args_str) = build_args_and_setup(
883 &fixture.input,
884 args,
885 class_name,
886 effective_options_type,
887 effective_options_via,
888 enum_fields,
889 nested_types,
890 &fixture.id,
891 );
892
893 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
894 e2e_config
895 .call
896 .overrides
897 .get("csharp")
898 .and_then(|o| o.client_factory.as_deref())
899 });
900 let mut client_factory_setup = String::new();
901 if let Some(factory) = client_factory {
902 let factory_name = factory.to_upper_camel_case();
903 let fixture_id = &fixture.id;
904 client_factory_setup.push_str(&format!(
905 " var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"
906 ));
907 client_factory_setup.push_str(&format!(
908 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
909 ));
910 }
911
912 let call_target = if client_factory.is_some() { "client" } else { class_name };
913 let call_expr = format!("{call_target}.{function_name}({args_str})");
914
915 let mut needs_finish_reason = false;
917 let mut needs_tool_calls_json = false;
918 let mut needs_tool_calls_0_function_name = false;
919 let mut needs_total_tokens = false;
920 for a in &fixture.assertions {
921 if let Some(f) = a.field.as_deref() {
922 match f {
923 "finish_reason" => needs_finish_reason = true,
924 "tool_calls" => needs_tool_calls_json = true,
925 "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
926 "usage.total_tokens" => needs_total_tokens = true,
927 _ => {}
928 }
929 }
930 }
931
932 let mut body = String::new();
933 let _ = writeln!(body, " [Fact]");
934 let _ = writeln!(body, " public async Task Test_{method_name}()");
935 let _ = writeln!(body, " {{");
936 let _ = writeln!(body, " // {description}");
937 if !client_factory_setup.is_empty() {
938 body.push_str(&client_factory_setup);
939 }
940 for line in &setup_lines {
941 let _ = writeln!(body, " {line}");
942 }
943
944 if expects_error {
945 let _ = writeln!(
948 body,
949 " await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
950 );
951 let _ = writeln!(body, " await foreach (var _chunk in {call_expr}) {{ }}");
952 body.push_str(" });\n");
953 body.push_str(" }\n");
954 for line in body.lines() {
955 out.push_str(" ");
956 out.push_str(line);
957 out.push('\n');
958 }
959 return;
960 }
961
962 body.push_str(" var chunks = new List<ChatCompletionChunk>();\n");
963 body.push_str(" var streamContent = new System.Text.StringBuilder();\n");
964 body.push_str(" var streamComplete = false;\n");
965 if needs_finish_reason {
966 body.push_str(" string? lastFinishReason = null;\n");
967 }
968 if needs_tool_calls_json {
969 body.push_str(" string? toolCallsJson = null;\n");
970 }
971 if needs_tool_calls_0_function_name {
972 body.push_str(" string? toolCalls0FunctionName = null;\n");
973 }
974 if needs_total_tokens {
975 body.push_str(" long? totalTokens = null;\n");
976 }
977 let _ = writeln!(body, " await foreach (var chunk in {call_expr})");
978 body.push_str(" {\n");
979 body.push_str(" chunks.Add(chunk);\n");
980 body.push_str(
981 " var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
982 );
983 body.push_str(" if (choice != null)\n");
984 body.push_str(" {\n");
985 body.push_str(" var delta = choice.Delta;\n");
986 body.push_str(" if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
987 body.push_str(" {\n");
988 body.push_str(" streamContent.Append(delta.Content);\n");
989 body.push_str(" }\n");
990 if needs_finish_reason {
991 body.push_str(" if (choice.FinishReason != null)\n");
992 body.push_str(" {\n");
993 body.push_str(" lastFinishReason = choice.FinishReason?.ToString()?.ToLower();\n");
994 body.push_str(" }\n");
995 }
996 if needs_tool_calls_json || needs_tool_calls_0_function_name {
997 body.push_str(" var tcs = delta?.ToolCalls;\n");
998 body.push_str(" if (tcs != null && tcs.Count > 0)\n");
999 body.push_str(" {\n");
1000 if needs_tool_calls_json {
1001 body.push_str(
1002 " toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
1003 );
1004 }
1005 if needs_tool_calls_0_function_name {
1006 body.push_str(" toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
1007 }
1008 body.push_str(" }\n");
1009 }
1010 body.push_str(" }\n");
1011 if needs_total_tokens {
1012 body.push_str(" if (chunk.Usage != null)\n");
1013 body.push_str(" {\n");
1014 body.push_str(" totalTokens = chunk.Usage.TotalTokens;\n");
1015 body.push_str(" }\n");
1016 }
1017 body.push_str(" }\n");
1018 body.push_str(" streamComplete = true;\n");
1019
1020 let mut had_explicit_complete = false;
1022 for assertion in &fixture.assertions {
1023 if assertion.field.as_deref() == Some("stream_complete") {
1024 had_explicit_complete = true;
1025 }
1026 emit_chat_stream_assertion(&mut body, assertion);
1027 }
1028 if !had_explicit_complete {
1029 body.push_str(" Assert.True(streamComplete);\n");
1030 }
1031
1032 body.push_str(" }\n");
1033
1034 for line in body.lines() {
1035 out.push_str(" ");
1036 out.push_str(line);
1037 out.push('\n');
1038 }
1039}
1040
1041fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
1045 let atype = assertion.assertion_type.as_str();
1046 if atype == "not_error" || atype == "error" {
1047 return;
1048 }
1049 let field = assertion.field.as_deref().unwrap_or("");
1050
1051 enum Kind {
1052 Chunks,
1053 Bool,
1054 Str,
1055 IntTokens,
1056 Json,
1057 Unsupported,
1058 }
1059
1060 let (expr, kind) = match field {
1061 "chunks" => ("chunks", Kind::Chunks),
1062 "stream_content" => ("streamContent.ToString()", Kind::Str),
1063 "stream_complete" => ("streamComplete", Kind::Bool),
1064 "no_chunks_after_done" => ("streamComplete", Kind::Bool),
1065 "finish_reason" => ("lastFinishReason", Kind::Str),
1066 "tool_calls" => ("toolCallsJson", Kind::Json),
1067 "tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
1068 "usage.total_tokens" => ("totalTokens", Kind::IntTokens),
1069 _ => ("", Kind::Unsupported),
1070 };
1071
1072 if matches!(kind, Kind::Unsupported) {
1073 let _ = writeln!(
1074 out,
1075 " // skipped: streaming assertion on unsupported field '{field}'"
1076 );
1077 return;
1078 }
1079
1080 match (atype, &kind) {
1081 ("count_min", Kind::Chunks) => {
1082 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1083 let _ = writeln!(
1084 out,
1085 " Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
1086 );
1087 }
1088 }
1089 ("count_equals", Kind::Chunks) => {
1090 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1091 let _ = writeln!(out, " Assert.Equal({n}, chunks.Count);");
1092 }
1093 }
1094 ("equals", Kind::Str) => {
1095 if let Some(val) = &assertion.value {
1096 let cs_val = json_to_csharp(val);
1097 let _ = writeln!(out, " Assert.Equal({cs_val}, {expr});");
1098 }
1099 }
1100 ("contains", Kind::Str) => {
1101 if let Some(val) = &assertion.value {
1102 let cs_val = json_to_csharp(val);
1103 let _ = writeln!(out, " Assert.Contains({cs_val}, {expr} ?? string.Empty);");
1104 }
1105 }
1106 ("not_empty", Kind::Str) => {
1107 let _ = writeln!(out, " Assert.False(string.IsNullOrEmpty({expr}));");
1108 }
1109 ("not_empty", Kind::Json) => {
1110 let _ = writeln!(out, " Assert.NotNull({expr});");
1111 }
1112 ("is_empty", Kind::Str) => {
1113 let _ = writeln!(out, " Assert.True(string.IsNullOrEmpty({expr}));");
1114 }
1115 ("is_true", Kind::Bool) => {
1116 let _ = writeln!(out, " Assert.True({expr});");
1117 }
1118 ("is_false", Kind::Bool) => {
1119 let _ = writeln!(out, " Assert.False({expr});");
1120 }
1121 ("greater_than_or_equal", Kind::IntTokens) => {
1122 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1123 let _ = writeln!(out, " Assert.True({expr} >= {n}, \"expected >= {n}\");");
1124 }
1125 }
1126 ("equals", Kind::IntTokens) => {
1127 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1128 let _ = writeln!(out, " Assert.Equal((long?){n}, {expr});");
1129 }
1130 }
1131 _ => {
1132 let _ = writeln!(
1133 out,
1134 " // skipped: streaming assertion '{atype}' on field '{field}' not supported"
1135 );
1136 }
1137 }
1138}
1139
1140#[allow(clippy::too_many_arguments)]
1144fn build_args_and_setup(
1145 input: &serde_json::Value,
1146 args: &[crate::config::ArgMapping],
1147 class_name: &str,
1148 options_type: Option<&str>,
1149 options_via: Option<&str>,
1150 enum_fields: &HashMap<String, String>,
1151 nested_types: &HashMap<String, String>,
1152 fixture_id: &str,
1153) -> (Vec<String>, String) {
1154 if args.is_empty() {
1155 return (Vec::new(), String::new());
1156 }
1157
1158 let mut setup_lines: Vec<String> = Vec::new();
1159 let mut parts: Vec<String> = Vec::new();
1160
1161 for arg in args {
1162 if arg.arg_type == "bytes" {
1163 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1165 let val = input.get(field);
1166 match val {
1167 None | Some(serde_json::Value::Null) if arg.optional => {
1168 parts.push("null".to_string());
1169 }
1170 None | Some(serde_json::Value::Null) => {
1171 parts.push("System.Array.Empty<byte>()".to_string());
1172 }
1173 Some(v) => {
1174 if let Some(s) = v.as_str() {
1179 let bytes_code = classify_bytes_value_csharp(s);
1180 parts.push(bytes_code);
1181 } else {
1182 let cs_str = json_to_csharp(v);
1184 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
1185 }
1186 }
1187 }
1188 continue;
1189 }
1190
1191 if arg.arg_type == "mock_url" {
1192 setup_lines.push(format!(
1193 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1194 arg.name,
1195 ));
1196 parts.push(arg.name.clone());
1197 continue;
1198 }
1199
1200 if arg.arg_type == "handle" {
1201 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
1203 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1204 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1205 if config_value.is_null()
1206 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1207 {
1208 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
1209 } else {
1210 let sorted = sort_discriminator_first(config_value.clone());
1214 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1215 let name = &arg.name;
1216 setup_lines.push(format!(
1217 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
1218 escape_csharp(&json_str),
1219 ));
1220 setup_lines.push(format!(
1221 "var {} = {class_name}.{constructor_name}({name}Config);",
1222 arg.name,
1223 name = name,
1224 ));
1225 }
1226 parts.push(arg.name.clone());
1227 continue;
1228 }
1229
1230 let val: Option<&serde_json::Value> = if arg.field == "input" {
1233 Some(input)
1234 } else {
1235 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1236 input.get(field)
1237 };
1238 match val {
1239 None | Some(serde_json::Value::Null) if arg.optional => {
1240 parts.push("null".to_string());
1243 continue;
1244 }
1245 None | Some(serde_json::Value::Null) => {
1246 let default_val = match arg.arg_type.as_str() {
1250 "string" => "\"\"".to_string(),
1251 "int" | "integer" => "0".to_string(),
1252 "float" | "number" => "0.0d".to_string(),
1253 "bool" | "boolean" => "false".to_string(),
1254 "json_object" => {
1255 if let Some(opts_type) = options_type {
1256 format!("new {opts_type}()")
1257 } else {
1258 "null".to_string()
1259 }
1260 }
1261 _ => "null".to_string(),
1262 };
1263 parts.push(default_val);
1264 }
1265 Some(v) => {
1266 if arg.arg_type == "json_object" {
1267 if options_via == Some("from_json")
1273 && let Some(opts_type) = options_type
1274 {
1275 let sorted = sort_discriminator_first(v.clone());
1276 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1277 let escaped = escape_csharp(&json_str);
1278 parts.push(format!(
1279 "JsonSerializer.Deserialize<{opts_type}>(\"{escaped}\", ConfigOptions)!",
1280 ));
1281 continue;
1282 }
1283 if let Some(arr) = v.as_array() {
1285 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
1286 continue;
1287 }
1288 if let Some(opts_type) = options_type {
1290 if let Some(obj) = v.as_object() {
1291 parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
1292 continue;
1293 }
1294 }
1295 }
1296 parts.push(json_to_csharp(v));
1297 }
1298 }
1299 }
1300
1301 (setup_lines, parts.join(", "))
1302}
1303
1304fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
1312 match element_type {
1313 Some("BatchBytesItem") => {
1314 let items: Vec<String> = arr
1315 .iter()
1316 .filter_map(|v| v.as_object())
1317 .map(|obj| {
1318 let content = obj.get("content").and_then(|v| v.as_array());
1319 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1320 let content_code = if let Some(arr) = content {
1321 let bytes: Vec<String> = arr
1322 .iter()
1323 .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1324 .collect();
1325 format!("new byte[] {{ {} }}", bytes.join(", "))
1326 } else {
1327 "new byte[] { }".to_string()
1328 };
1329 format!(
1330 "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1331 content_code, mime_type
1332 )
1333 })
1334 .collect();
1335 format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1336 }
1337 Some("BatchFileItem") => {
1338 let items: Vec<String> = arr
1339 .iter()
1340 .filter_map(|v| v.as_object())
1341 .map(|obj| {
1342 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1343 format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1344 })
1345 .collect();
1346 format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1347 }
1348 Some("f32") => {
1349 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1350 format!("new List<float>() {{ {} }}", items.join(", "))
1351 }
1352 Some("(String, String)") => {
1353 let items: Vec<String> = arr
1354 .iter()
1355 .map(|v| {
1356 let strs: Vec<String> = v
1357 .as_array()
1358 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1359 format!("new List<string>() {{ {} }}", strs.join(", "))
1360 })
1361 .collect();
1362 format!("new List<List<string>>() {{ {} }}", items.join(", "))
1363 }
1364 Some(et)
1365 if et != "f32"
1366 && et != "(String, String)"
1367 && et != "string"
1368 && et != "BatchBytesItem"
1369 && et != "BatchFileItem" =>
1370 {
1371 let items: Vec<String> = arr
1373 .iter()
1374 .map(|v| {
1375 let json_str = serde_json::to_string(v).unwrap_or_default();
1376 let escaped = escape_csharp(&json_str);
1377 format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1378 })
1379 .collect();
1380 format!("new List<{et}>() {{ {} }}", items.join(", "))
1381 }
1382 _ => {
1383 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1384 format!("new List<string>() {{ {} }}", items.join(", "))
1385 }
1386 }
1387}
1388
1389fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1393 let parts: Vec<&str> = field.split('.').collect();
1394 if parts.len() >= 3 && parts.len() <= 4 {
1395 if parts[0] == "metadata" && parts[1] == "format" {
1397 let variant_name = parts[2];
1398 let known_variants = [
1400 "pdf",
1401 "docx",
1402 "excel",
1403 "email",
1404 "pptx",
1405 "archive",
1406 "image",
1407 "xml",
1408 "text",
1409 "html",
1410 "ocr",
1411 "csv",
1412 "bibtex",
1413 "citation",
1414 "fiction_book",
1415 "dbf",
1416 "jats",
1417 "epub",
1418 "pst",
1419 "code",
1420 ];
1421 if known_variants.contains(&variant_name) {
1422 let variant_pascal = variant_name.to_upper_camel_case();
1423 if parts.len() == 4 {
1424 let inner_field = parts[3];
1425 return Some((
1426 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1427 variant_pascal,
1428 inner_field.to_string(),
1429 ));
1430 } else if parts.len() == 3 {
1431 return Some((
1433 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1434 variant_pascal,
1435 String::new(),
1436 ));
1437 }
1438 }
1439 }
1440 }
1441 None
1442}
1443
1444fn render_discriminated_union_assertion(
1448 out: &mut String,
1449 assertion: &Assertion,
1450 variant_var: &str,
1451 inner_field: &str,
1452 _result_is_vec: bool,
1453) {
1454 if inner_field.is_empty() {
1455 return; }
1457
1458 let field_pascal = inner_field.to_upper_camel_case();
1459 let field_expr = format!("{variant_var}.Value.{field_pascal}");
1460
1461 match assertion.assertion_type.as_str() {
1462 "equals" => {
1463 if let Some(expected) = &assertion.value {
1464 let cs_val = json_to_csharp(expected);
1465 if expected.is_string() {
1466 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}!.Trim());");
1467 } else if expected.as_bool() == Some(true) {
1468 let _ = writeln!(out, " Assert.True({field_expr});");
1469 } else if expected.as_bool() == Some(false) {
1470 let _ = writeln!(out, " Assert.False({field_expr});");
1471 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1472 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
1473 } else {
1474 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
1475 }
1476 }
1477 }
1478 "greater_than_or_equal" => {
1479 if let Some(val) = &assertion.value {
1480 let cs_val = json_to_csharp(val);
1481 let _ = writeln!(
1482 out,
1483 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1484 );
1485 }
1486 }
1487 "contains_all" => {
1488 if let Some(values) = &assertion.values {
1489 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1490 for val in values {
1491 let lower_val = val.as_str().map(|s| s.to_lowercase());
1492 let cs_val = lower_val
1493 .as_deref()
1494 .map(|s| format!("\"{}\"", escape_csharp(s)))
1495 .unwrap_or_else(|| json_to_csharp(val));
1496 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1497 }
1498 }
1499 }
1500 "contains" => {
1501 if let Some(expected) = &assertion.value {
1502 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1503 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1504 let cs_val = lower_expected
1505 .as_deref()
1506 .map(|s| format!("\"{}\"", escape_csharp(s)))
1507 .unwrap_or_else(|| json_to_csharp(expected));
1508 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1509 }
1510 }
1511 "not_empty" => {
1512 let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
1513 }
1514 "is_empty" => {
1515 let _ = writeln!(out, " Assert.Empty({field_expr});");
1516 }
1517 _ => {
1518 let _ = writeln!(
1519 out,
1520 " // skipped: assertion type '{}' not yet supported for discriminated union fields",
1521 assertion.assertion_type
1522 );
1523 }
1524 }
1525}
1526
1527#[allow(clippy::too_many_arguments)]
1528fn render_assertion(
1529 out: &mut String,
1530 assertion: &Assertion,
1531 result_var: &str,
1532 class_name: &str,
1533 exception_class: &str,
1534 field_resolver: &FieldResolver,
1535 result_is_simple: bool,
1536 result_is_vec: bool,
1537 result_is_array: bool,
1538 result_is_bytes: bool,
1539 fields_enum: &std::collections::HashSet<String>,
1540) {
1541 if result_is_bytes {
1545 match assertion.assertion_type.as_str() {
1546 "not_empty" => {
1547 let _ = writeln!(out, " Assert.NotEmpty({result_var});");
1548 return;
1549 }
1550 "is_empty" => {
1551 let _ = writeln!(out, " Assert.Empty({result_var});");
1552 return;
1553 }
1554 "count_equals" | "length_equals" => {
1555 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1556 let _ = writeln!(out, " Assert.Equal({n}, {result_var}.Length);");
1557 }
1558 return;
1559 }
1560 "count_min" | "length_min" => {
1561 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1562 let _ = writeln!(out, " Assert.True({result_var}.Length >= {n});");
1563 }
1564 return;
1565 }
1566 _ => {
1567 let _ = writeln!(
1571 out,
1572 " // skipped: assertion type '{}' not supported on byte[] result",
1573 assertion.assertion_type
1574 );
1575 return;
1576 }
1577 }
1578 }
1579 if let Some(f) = &assertion.field {
1582 match f.as_str() {
1583 "chunks_have_content" => {
1584 let synthetic_pred =
1585 format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
1586 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1587 "is_true" => "is_true",
1588 "is_false" => "is_false",
1589 _ => {
1590 out.push_str(&format!(
1591 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1592 ));
1593 return;
1594 }
1595 };
1596 let rendered = crate::template_env::render(
1597 "csharp/assertion.jinja",
1598 minijinja::context! {
1599 assertion_type => "synthetic_assertion",
1600 synthetic_pred => synthetic_pred,
1601 synthetic_pred_type => synthetic_pred_type,
1602 },
1603 );
1604 out.push_str(&rendered);
1605 return;
1606 }
1607 "chunks_have_embeddings" => {
1608 let synthetic_pred =
1609 format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
1610 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1611 "is_true" => "is_true",
1612 "is_false" => "is_false",
1613 _ => {
1614 out.push_str(&format!(
1615 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1616 ));
1617 return;
1618 }
1619 };
1620 let rendered = crate::template_env::render(
1621 "csharp/assertion.jinja",
1622 minijinja::context! {
1623 assertion_type => "synthetic_assertion",
1624 synthetic_pred => synthetic_pred,
1625 synthetic_pred_type => synthetic_pred_type,
1626 },
1627 );
1628 out.push_str(&rendered);
1629 return;
1630 }
1631 "embeddings" => {
1635 match assertion.assertion_type.as_str() {
1636 "count_equals" => {
1637 if let Some(val) = &assertion.value {
1638 if let Some(n) = val.as_u64() {
1639 let rendered = crate::template_env::render(
1640 "csharp/assertion.jinja",
1641 minijinja::context! {
1642 assertion_type => "synthetic_embeddings_count_equals",
1643 synthetic_pred => format!("{result_var}.Count"),
1644 n => n,
1645 },
1646 );
1647 out.push_str(&rendered);
1648 }
1649 }
1650 }
1651 "count_min" => {
1652 if let Some(val) = &assertion.value {
1653 if let Some(n) = val.as_u64() {
1654 let rendered = crate::template_env::render(
1655 "csharp/assertion.jinja",
1656 minijinja::context! {
1657 assertion_type => "synthetic_embeddings_count_min",
1658 synthetic_pred => format!("{result_var}.Count"),
1659 n => n,
1660 },
1661 );
1662 out.push_str(&rendered);
1663 }
1664 }
1665 }
1666 "not_empty" => {
1667 let rendered = crate::template_env::render(
1668 "csharp/assertion.jinja",
1669 minijinja::context! {
1670 assertion_type => "synthetic_embeddings_not_empty",
1671 synthetic_pred => result_var.to_string(),
1672 },
1673 );
1674 out.push_str(&rendered);
1675 }
1676 "is_empty" => {
1677 let rendered = crate::template_env::render(
1678 "csharp/assertion.jinja",
1679 minijinja::context! {
1680 assertion_type => "synthetic_embeddings_is_empty",
1681 synthetic_pred => result_var.to_string(),
1682 },
1683 );
1684 out.push_str(&rendered);
1685 }
1686 _ => {
1687 out.push_str(
1688 " // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
1689 );
1690 }
1691 }
1692 return;
1693 }
1694 "embedding_dimensions" => {
1695 let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
1696 match assertion.assertion_type.as_str() {
1697 "equals" => {
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 => "synthetic_embedding_dimensions_equals",
1704 synthetic_pred => expr,
1705 n => n,
1706 },
1707 );
1708 out.push_str(&rendered);
1709 }
1710 }
1711 }
1712 "greater_than" => {
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 => "synthetic_embedding_dimensions_greater_than",
1719 synthetic_pred => expr,
1720 n => n,
1721 },
1722 );
1723 out.push_str(&rendered);
1724 }
1725 }
1726 }
1727 _ => {
1728 out.push_str(" // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
1729 }
1730 }
1731 return;
1732 }
1733 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1734 let synthetic_pred = match f.as_str() {
1735 "embeddings_valid" => {
1736 format!("{result_var}.All(e => e.Count > 0)")
1737 }
1738 "embeddings_finite" => {
1739 format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
1740 }
1741 "embeddings_non_zero" => {
1742 format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
1743 }
1744 "embeddings_normalized" => {
1745 format!(
1746 "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
1747 )
1748 }
1749 _ => unreachable!(),
1750 };
1751 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1752 "is_true" => "is_true",
1753 "is_false" => "is_false",
1754 _ => {
1755 out.push_str(&format!(
1756 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1757 ));
1758 return;
1759 }
1760 };
1761 let rendered = crate::template_env::render(
1762 "csharp/assertion.jinja",
1763 minijinja::context! {
1764 assertion_type => "synthetic_assertion",
1765 synthetic_pred => synthetic_pred,
1766 synthetic_pred_type => synthetic_pred_type,
1767 },
1768 );
1769 out.push_str(&rendered);
1770 return;
1771 }
1772 "keywords" | "keywords_count" => {
1775 let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
1776 let rendered = crate::template_env::render(
1777 "csharp/assertion.jinja",
1778 minijinja::context! {
1779 skipped_reason => skipped_reason,
1780 },
1781 );
1782 out.push_str(&rendered);
1783 return;
1784 }
1785 _ => {}
1786 }
1787 }
1788
1789 if let Some(f) = &assertion.field {
1791 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1792 let skipped_reason = format!("field '{f}' not available on result type");
1793 let rendered = crate::template_env::render(
1794 "csharp/assertion.jinja",
1795 minijinja::context! {
1796 skipped_reason => skipped_reason,
1797 },
1798 );
1799 out.push_str(&rendered);
1800 return;
1801 }
1802 }
1803
1804 let is_count_assertion = matches!(
1807 assertion.assertion_type.as_str(),
1808 "count_equals" | "count_min" | "count_max"
1809 );
1810 let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
1811 let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
1812
1813 let effective_result_var: String = if result_is_vec && !use_list_directly {
1814 format!("{result_var}[0]")
1815 } else {
1816 result_var.to_string()
1817 };
1818
1819 let is_discriminated_union = assertion
1821 .field
1822 .as_ref()
1823 .is_some_and(|f| parse_discriminated_union_access(f).is_some());
1824
1825 if is_discriminated_union {
1827 if let Some((_, variant_name, inner_field)) = assertion
1828 .field
1829 .as_ref()
1830 .and_then(|f| parse_discriminated_union_access(f))
1831 {
1832 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1834 inner_field.hash(&mut hasher);
1835 let var_hash = format!("{:x}", hasher.finish());
1836 let variant_var = format!("variant_{}", &var_hash[..8]);
1837 let _ = writeln!(
1838 out,
1839 " if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
1840 variant_name, &variant_var
1841 );
1842 let _ = writeln!(out, " {{");
1843 render_discriminated_union_assertion(out, assertion, &variant_var, &inner_field, result_is_vec);
1844 let _ = writeln!(out, " }}");
1845 let _ = writeln!(out, " else");
1846 let _ = writeln!(out, " {{");
1847 let _ = writeln!(
1848 out,
1849 " Assert.Fail(\"Expected {} format metadata\");",
1850 variant_name.to_lowercase()
1851 );
1852 let _ = writeln!(out, " }}");
1853 return;
1854 }
1855 }
1856
1857 let field_expr = if result_is_simple {
1858 effective_result_var.clone()
1859 } else {
1860 match &assertion.field {
1861 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
1862 _ => effective_result_var.clone(),
1863 }
1864 };
1865
1866 let field_needs_json_serialize = if result_is_simple {
1870 result_is_array
1873 } else {
1874 match &assertion.field {
1875 Some(f) if !f.is_empty() => field_resolver.is_array(f),
1876 _ => !result_is_simple,
1878 }
1879 };
1880 let field_as_str = if field_needs_json_serialize {
1882 format!("JsonSerializer.Serialize({field_expr})")
1883 } else {
1884 format!("{field_expr}.ToString()")
1885 };
1886
1887 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1891 let resolved = field_resolver.resolve(f);
1892 fields_enum.contains(f) || fields_enum.contains(resolved)
1893 });
1894
1895 match assertion.assertion_type.as_str() {
1896 "equals" => {
1897 if let Some(expected) = &assertion.value {
1898 if field_is_enum && expected.is_string() {
1902 let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
1903 let _ = writeln!(
1904 out,
1905 " Assert.Equal(\"{}\", {field_expr}?.ToString()?.ToLower());",
1906 escape_csharp(&s_lower)
1907 );
1908 return;
1909 }
1910 let cs_val = json_to_csharp(expected);
1911 let is_string_val = expected.is_string();
1912 let is_bool_true = expected.as_bool() == Some(true);
1913 let is_bool_false = expected.as_bool() == Some(false);
1914 let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
1915
1916 let rendered = crate::template_env::render(
1917 "csharp/assertion.jinja",
1918 minijinja::context! {
1919 assertion_type => "equals",
1920 field_expr => field_expr.clone(),
1921 cs_val => cs_val,
1922 is_string_val => is_string_val,
1923 is_bool_true => is_bool_true,
1924 is_bool_false => is_bool_false,
1925 is_integer_val => is_integer_val,
1926 },
1927 );
1928 out.push_str(&rendered);
1929 }
1930 }
1931 "contains" => {
1932 if let Some(expected) = &assertion.value {
1933 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1940 let cs_val = lower_expected
1941 .as_deref()
1942 .map(|s| format!("\"{}\"", escape_csharp(s)))
1943 .unwrap_or_else(|| json_to_csharp(expected));
1944
1945 let rendered = crate::template_env::render(
1946 "csharp/assertion.jinja",
1947 minijinja::context! {
1948 assertion_type => "contains",
1949 field_as_str => field_as_str.clone(),
1950 cs_val => cs_val,
1951 },
1952 );
1953 out.push_str(&rendered);
1954 }
1955 }
1956 "contains_all" => {
1957 if let Some(values) = &assertion.values {
1958 let values_cs_lower: Vec<String> = values
1959 .iter()
1960 .map(|val| {
1961 let lower_val = val.as_str().map(|s| s.to_lowercase());
1962 lower_val
1963 .as_deref()
1964 .map(|s| format!("\"{}\"", escape_csharp(s)))
1965 .unwrap_or_else(|| json_to_csharp(val))
1966 })
1967 .collect();
1968
1969 let rendered = crate::template_env::render(
1970 "csharp/assertion.jinja",
1971 minijinja::context! {
1972 assertion_type => "contains_all",
1973 field_as_str => field_as_str.clone(),
1974 values_cs_lower => values_cs_lower,
1975 },
1976 );
1977 out.push_str(&rendered);
1978 }
1979 }
1980 "not_contains" => {
1981 if let Some(expected) = &assertion.value {
1982 let cs_val = json_to_csharp(expected);
1983
1984 let rendered = crate::template_env::render(
1985 "csharp/assertion.jinja",
1986 minijinja::context! {
1987 assertion_type => "not_contains",
1988 field_as_str => field_as_str.clone(),
1989 cs_val => cs_val,
1990 },
1991 );
1992 out.push_str(&rendered);
1993 }
1994 }
1995 "not_empty" => {
1996 let rendered = crate::template_env::render(
1997 "csharp/assertion.jinja",
1998 minijinja::context! {
1999 assertion_type => "not_empty",
2000 field_expr => field_expr.clone(),
2001 field_needs_json_serialize => field_needs_json_serialize,
2002 },
2003 );
2004 out.push_str(&rendered);
2005 }
2006 "is_empty" => {
2007 let rendered = crate::template_env::render(
2008 "csharp/assertion.jinja",
2009 minijinja::context! {
2010 assertion_type => "is_empty",
2011 field_expr => field_expr.clone(),
2012 field_needs_json_serialize => field_needs_json_serialize,
2013 },
2014 );
2015 out.push_str(&rendered);
2016 }
2017 "contains_any" => {
2018 if let Some(values) = &assertion.values {
2019 let checks: Vec<String> = values
2020 .iter()
2021 .map(|v| {
2022 let cs_val = json_to_csharp(v);
2023 format!("{field_as_str}.Contains({cs_val})")
2024 })
2025 .collect();
2026 let contains_any_expr = checks.join(" || ");
2027
2028 let rendered = crate::template_env::render(
2029 "csharp/assertion.jinja",
2030 minijinja::context! {
2031 assertion_type => "contains_any",
2032 contains_any_expr => contains_any_expr,
2033 },
2034 );
2035 out.push_str(&rendered);
2036 }
2037 }
2038 "greater_than" => {
2039 if let Some(val) = &assertion.value {
2040 let cs_val = json_to_csharp(val);
2041
2042 let rendered = crate::template_env::render(
2043 "csharp/assertion.jinja",
2044 minijinja::context! {
2045 assertion_type => "greater_than",
2046 field_expr => field_expr.clone(),
2047 cs_val => cs_val,
2048 },
2049 );
2050 out.push_str(&rendered);
2051 }
2052 }
2053 "less_than" => {
2054 if let Some(val) = &assertion.value {
2055 let cs_val = json_to_csharp(val);
2056
2057 let rendered = crate::template_env::render(
2058 "csharp/assertion.jinja",
2059 minijinja::context! {
2060 assertion_type => "less_than",
2061 field_expr => field_expr.clone(),
2062 cs_val => cs_val,
2063 },
2064 );
2065 out.push_str(&rendered);
2066 }
2067 }
2068 "greater_than_or_equal" => {
2069 if let Some(val) = &assertion.value {
2070 let cs_val = json_to_csharp(val);
2071
2072 let rendered = crate::template_env::render(
2073 "csharp/assertion.jinja",
2074 minijinja::context! {
2075 assertion_type => "greater_than_or_equal",
2076 field_expr => field_expr.clone(),
2077 cs_val => cs_val,
2078 },
2079 );
2080 out.push_str(&rendered);
2081 }
2082 }
2083 "less_than_or_equal" => {
2084 if let Some(val) = &assertion.value {
2085 let cs_val = json_to_csharp(val);
2086
2087 let rendered = crate::template_env::render(
2088 "csharp/assertion.jinja",
2089 minijinja::context! {
2090 assertion_type => "less_than_or_equal",
2091 field_expr => field_expr.clone(),
2092 cs_val => cs_val,
2093 },
2094 );
2095 out.push_str(&rendered);
2096 }
2097 }
2098 "starts_with" => {
2099 if let Some(expected) = &assertion.value {
2100 let cs_val = json_to_csharp(expected);
2101
2102 let rendered = crate::template_env::render(
2103 "csharp/assertion.jinja",
2104 minijinja::context! {
2105 assertion_type => "starts_with",
2106 field_expr => field_expr.clone(),
2107 cs_val => cs_val,
2108 },
2109 );
2110 out.push_str(&rendered);
2111 }
2112 }
2113 "ends_with" => {
2114 if let Some(expected) = &assertion.value {
2115 let cs_val = json_to_csharp(expected);
2116
2117 let rendered = crate::template_env::render(
2118 "csharp/assertion.jinja",
2119 minijinja::context! {
2120 assertion_type => "ends_with",
2121 field_expr => field_expr.clone(),
2122 cs_val => cs_val,
2123 },
2124 );
2125 out.push_str(&rendered);
2126 }
2127 }
2128 "min_length" => {
2129 if let Some(val) = &assertion.value {
2130 if let Some(n) = val.as_u64() {
2131 let rendered = crate::template_env::render(
2132 "csharp/assertion.jinja",
2133 minijinja::context! {
2134 assertion_type => "min_length",
2135 field_expr => field_expr.clone(),
2136 n => n,
2137 },
2138 );
2139 out.push_str(&rendered);
2140 }
2141 }
2142 }
2143 "max_length" => {
2144 if let Some(val) = &assertion.value {
2145 if let Some(n) = val.as_u64() {
2146 let rendered = crate::template_env::render(
2147 "csharp/assertion.jinja",
2148 minijinja::context! {
2149 assertion_type => "max_length",
2150 field_expr => field_expr.clone(),
2151 n => n,
2152 },
2153 );
2154 out.push_str(&rendered);
2155 }
2156 }
2157 }
2158 "count_min" => {
2159 if let Some(val) = &assertion.value {
2160 if let Some(n) = val.as_u64() {
2161 let rendered = crate::template_env::render(
2162 "csharp/assertion.jinja",
2163 minijinja::context! {
2164 assertion_type => "count_min",
2165 field_expr => field_expr.clone(),
2166 n => n,
2167 },
2168 );
2169 out.push_str(&rendered);
2170 }
2171 }
2172 }
2173 "count_equals" => {
2174 if let Some(val) = &assertion.value {
2175 if let Some(n) = val.as_u64() {
2176 let rendered = crate::template_env::render(
2177 "csharp/assertion.jinja",
2178 minijinja::context! {
2179 assertion_type => "count_equals",
2180 field_expr => field_expr.clone(),
2181 n => n,
2182 },
2183 );
2184 out.push_str(&rendered);
2185 }
2186 }
2187 }
2188 "is_true" => {
2189 let rendered = crate::template_env::render(
2190 "csharp/assertion.jinja",
2191 minijinja::context! {
2192 assertion_type => "is_true",
2193 field_expr => field_expr.clone(),
2194 },
2195 );
2196 out.push_str(&rendered);
2197 }
2198 "is_false" => {
2199 let rendered = crate::template_env::render(
2200 "csharp/assertion.jinja",
2201 minijinja::context! {
2202 assertion_type => "is_false",
2203 field_expr => field_expr.clone(),
2204 },
2205 );
2206 out.push_str(&rendered);
2207 }
2208 "not_error" => {
2209 let rendered = crate::template_env::render(
2211 "csharp/assertion.jinja",
2212 minijinja::context! {
2213 assertion_type => "not_error",
2214 },
2215 );
2216 out.push_str(&rendered);
2217 }
2218 "error" => {
2219 let rendered = crate::template_env::render(
2221 "csharp/assertion.jinja",
2222 minijinja::context! {
2223 assertion_type => "error",
2224 },
2225 );
2226 out.push_str(&rendered);
2227 }
2228 "method_result" => {
2229 if let Some(method_name) = &assertion.method {
2230 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2231 let check = assertion.check.as_deref().unwrap_or("is_true");
2232
2233 match check {
2234 "equals" => {
2235 if let Some(val) = &assertion.value {
2236 let is_check_bool_true = val.as_bool() == Some(true);
2237 let is_check_bool_false = val.as_bool() == Some(false);
2238 let cs_check_val = json_to_csharp(val);
2239
2240 let rendered = crate::template_env::render(
2241 "csharp/assertion.jinja",
2242 minijinja::context! {
2243 assertion_type => "method_result",
2244 check => "equals",
2245 call_expr => call_expr.clone(),
2246 is_check_bool_true => is_check_bool_true,
2247 is_check_bool_false => is_check_bool_false,
2248 cs_check_val => cs_check_val,
2249 },
2250 );
2251 out.push_str(&rendered);
2252 }
2253 }
2254 "is_true" => {
2255 let rendered = crate::template_env::render(
2256 "csharp/assertion.jinja",
2257 minijinja::context! {
2258 assertion_type => "method_result",
2259 check => "is_true",
2260 call_expr => call_expr.clone(),
2261 },
2262 );
2263 out.push_str(&rendered);
2264 }
2265 "is_false" => {
2266 let rendered = crate::template_env::render(
2267 "csharp/assertion.jinja",
2268 minijinja::context! {
2269 assertion_type => "method_result",
2270 check => "is_false",
2271 call_expr => call_expr.clone(),
2272 },
2273 );
2274 out.push_str(&rendered);
2275 }
2276 "greater_than_or_equal" => {
2277 if let Some(val) = &assertion.value {
2278 let check_n = val.as_u64().unwrap_or(0);
2279
2280 let rendered = crate::template_env::render(
2281 "csharp/assertion.jinja",
2282 minijinja::context! {
2283 assertion_type => "method_result",
2284 check => "greater_than_or_equal",
2285 call_expr => call_expr.clone(),
2286 check_n => check_n,
2287 },
2288 );
2289 out.push_str(&rendered);
2290 }
2291 }
2292 "count_min" => {
2293 if let Some(val) = &assertion.value {
2294 let check_n = val.as_u64().unwrap_or(0);
2295
2296 let rendered = crate::template_env::render(
2297 "csharp/assertion.jinja",
2298 minijinja::context! {
2299 assertion_type => "method_result",
2300 check => "count_min",
2301 call_expr => call_expr.clone(),
2302 check_n => check_n,
2303 },
2304 );
2305 out.push_str(&rendered);
2306 }
2307 }
2308 "is_error" => {
2309 let rendered = crate::template_env::render(
2310 "csharp/assertion.jinja",
2311 minijinja::context! {
2312 assertion_type => "method_result",
2313 check => "is_error",
2314 call_expr => call_expr.clone(),
2315 exception_class => exception_class,
2316 },
2317 );
2318 out.push_str(&rendered);
2319 }
2320 "contains" => {
2321 if let Some(val) = &assertion.value {
2322 let cs_check_val = json_to_csharp(val);
2323
2324 let rendered = crate::template_env::render(
2325 "csharp/assertion.jinja",
2326 minijinja::context! {
2327 assertion_type => "method_result",
2328 check => "contains",
2329 call_expr => call_expr.clone(),
2330 cs_check_val => cs_check_val,
2331 },
2332 );
2333 out.push_str(&rendered);
2334 }
2335 }
2336 other_check => {
2337 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2338 }
2339 }
2340 } else {
2341 panic!("C# e2e generator: method_result assertion missing 'method' field");
2342 }
2343 }
2344 "matches_regex" => {
2345 if let Some(expected) = &assertion.value {
2346 let cs_val = json_to_csharp(expected);
2347
2348 let rendered = crate::template_env::render(
2349 "csharp/assertion.jinja",
2350 minijinja::context! {
2351 assertion_type => "matches_regex",
2352 field_expr => field_expr.clone(),
2353 cs_val => cs_val,
2354 },
2355 );
2356 out.push_str(&rendered);
2357 }
2358 }
2359 other => {
2360 panic!("C# e2e generator: unsupported assertion type: {other}");
2361 }
2362 }
2363}
2364
2365fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
2372 match value {
2373 serde_json::Value::Object(map) => {
2374 let mut sorted = serde_json::Map::with_capacity(map.len());
2375 if let Some(type_val) = map.get("type") {
2377 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
2378 }
2379 for (k, v) in map {
2380 if k != "type" {
2381 sorted.insert(k, sort_discriminator_first(v));
2382 }
2383 }
2384 serde_json::Value::Object(sorted)
2385 }
2386 serde_json::Value::Array(arr) => {
2387 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
2388 }
2389 other => other,
2390 }
2391}
2392
2393fn json_to_csharp(value: &serde_json::Value) -> String {
2395 match value {
2396 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
2397 serde_json::Value::Bool(true) => "true".to_string(),
2398 serde_json::Value::Bool(false) => "false".to_string(),
2399 serde_json::Value::Number(n) => {
2400 if n.is_f64() {
2401 format!("{}d", n)
2402 } else {
2403 n.to_string()
2404 }
2405 }
2406 serde_json::Value::Null => "null".to_string(),
2407 serde_json::Value::Array(arr) => {
2408 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2409 format!("new[] {{ {} }}", items.join(", "))
2410 }
2411 serde_json::Value::Object(_) => {
2412 let json_str = serde_json::to_string(value).unwrap_or_default();
2413 format!("\"{}\"", escape_csharp(&json_str))
2414 }
2415 }
2416}
2417
2418fn default_csharp_nested_types() -> HashMap<String, String> {
2425 [
2426 ("chunking", "ChunkingConfig"),
2427 ("ocr", "OcrConfig"),
2428 ("images", "ImageExtractionConfig"),
2429 ("html_output", "HtmlOutputConfig"),
2430 ("language_detection", "LanguageDetectionConfig"),
2431 ("postprocessor", "PostProcessorConfig"),
2432 ("acceleration", "AccelerationConfig"),
2433 ("email", "EmailConfig"),
2434 ("pages", "PageConfig"),
2435 ("pdf_options", "PdfConfig"),
2436 ("layout", "LayoutDetectionConfig"),
2437 ("tree_sitter", "TreeSitterConfig"),
2438 ("structured_extraction", "StructuredExtractionConfig"),
2439 ("content_filter", "ContentFilterConfig"),
2440 ("token_reduction", "TokenReductionOptions"),
2441 ("security_limits", "SecurityLimits"),
2442 ("format", "FormatMetadata"),
2443 ]
2444 .iter()
2445 .map(|(k, v)| (k.to_string(), v.to_string()))
2446 .collect()
2447}
2448
2449fn csharp_object_initializer(
2457 obj: &serde_json::Map<String, serde_json::Value>,
2458 type_name: &str,
2459 enum_fields: &HashMap<String, String>,
2460 nested_types: &HashMap<String, String>,
2461) -> String {
2462 if obj.is_empty() {
2463 return format!("new {type_name}()");
2464 }
2465
2466 static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
2469
2470 let props: Vec<String> = obj
2471 .iter()
2472 .map(|(key, val)| {
2473 let pascal_key = key.to_upper_camel_case();
2474 let implicit_enum_type = IMPLICIT_ENUM_FIELDS
2475 .iter()
2476 .find(|(k, _)| *k == key.as_str())
2477 .map(|(_, t)| *t);
2478 let cs_val =
2479 if let Some(enum_type) = enum_fields.get(key.as_str()).map(String::as_str).or(implicit_enum_type) {
2480 if val.is_null() {
2482 "null".to_string()
2483 } else {
2484 let member = val
2485 .as_str()
2486 .map(|s| s.to_upper_camel_case())
2487 .unwrap_or_else(|| "null".to_string());
2488 format!("{enum_type}.{member}")
2489 }
2490 } else if let Some(nested_type) = nested_types.get(key.as_str()) {
2491 let normalized = normalize_csharp_enum_values(val, enum_fields);
2493 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
2494 format!(
2495 "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
2496 escape_csharp(&json_str)
2497 )
2498 } else if let Some(arr) = val.as_array() {
2499 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2501 format!("new List<string> {{ {} }}", items.join(", "))
2502 } else {
2503 json_to_csharp(val)
2504 };
2505 format!("{pascal_key} = {cs_val}")
2506 })
2507 .collect();
2508 format!("new {} {{ {} }}", type_name, props.join(", "))
2509}
2510
2511fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
2516 match value {
2517 serde_json::Value::Object(map) => {
2518 let mut result = map.clone();
2519 for (key, val) in result.iter_mut() {
2520 if enum_fields.contains_key(key) {
2521 if let Some(s) = val.as_str() {
2523 *val = serde_json::Value::String(s.to_lowercase());
2524 }
2525 }
2526 }
2527 serde_json::Value::Object(result)
2528 }
2529 other => other.clone(),
2530 }
2531}
2532
2533fn build_csharp_visitor(
2544 setup_lines: &mut Vec<String>,
2545 class_decls: &mut Vec<String>,
2546 fixture_id: &str,
2547 visitor_spec: &crate::fixture::VisitorSpec,
2548) -> String {
2549 use heck::ToUpperCamelCase;
2550 let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
2551 let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
2552
2553 setup_lines.push(format!("var {var_name} = new {class_name}();"));
2554
2555 let mut decl = String::new();
2557 decl.push_str(&format!(" private sealed class {class_name} : IHtmlVisitor\n"));
2558 decl.push_str(" {\n");
2559
2560 let all_methods = [
2562 "visit_element_start",
2563 "visit_element_end",
2564 "visit_text",
2565 "visit_link",
2566 "visit_image",
2567 "visit_heading",
2568 "visit_code_block",
2569 "visit_code_inline",
2570 "visit_list_item",
2571 "visit_list_start",
2572 "visit_list_end",
2573 "visit_table_start",
2574 "visit_table_row",
2575 "visit_table_end",
2576 "visit_blockquote",
2577 "visit_strong",
2578 "visit_emphasis",
2579 "visit_strikethrough",
2580 "visit_underline",
2581 "visit_subscript",
2582 "visit_superscript",
2583 "visit_mark",
2584 "visit_line_break",
2585 "visit_horizontal_rule",
2586 "visit_custom_element",
2587 "visit_definition_list_start",
2588 "visit_definition_term",
2589 "visit_definition_description",
2590 "visit_definition_list_end",
2591 "visit_form",
2592 "visit_input",
2593 "visit_button",
2594 "visit_audio",
2595 "visit_video",
2596 "visit_iframe",
2597 "visit_details",
2598 "visit_summary",
2599 "visit_figure_start",
2600 "visit_figcaption",
2601 "visit_figure_end",
2602 ];
2603
2604 for method_name in &all_methods {
2606 if let Some(action) = visitor_spec.callbacks.get(*method_name) {
2607 emit_csharp_visitor_method(&mut decl, method_name, action);
2608 } else {
2609 emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
2611 }
2612 }
2613
2614 decl.push_str(" }\n");
2615 class_decls.push(decl);
2616
2617 var_name
2618}
2619
2620fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
2622 let camel_method = method_to_camel(method_name);
2623 let params = match method_name {
2624 "visit_link" => "NodeContext ctx, string href, string text, string title",
2625 "visit_image" => "NodeContext ctx, string src, string alt, string title",
2626 "visit_heading" => "NodeContext ctx, uint level, string text, string id",
2627 "visit_code_block" => "NodeContext ctx, string lang, string code",
2628 "visit_code_inline"
2629 | "visit_strong"
2630 | "visit_emphasis"
2631 | "visit_strikethrough"
2632 | "visit_underline"
2633 | "visit_subscript"
2634 | "visit_superscript"
2635 | "visit_mark"
2636 | "visit_button"
2637 | "visit_summary"
2638 | "visit_figcaption"
2639 | "visit_definition_term"
2640 | "visit_definition_description" => "NodeContext ctx, string text",
2641 "visit_text" => "NodeContext ctx, string text",
2642 "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
2643 "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
2644 "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
2645 "visit_custom_element" => "NodeContext ctx, string tagName, string html",
2646 "visit_form" => "NodeContext ctx, string actionUrl, string method",
2647 "visit_input" => "NodeContext ctx, string inputType, string name, string value",
2648 "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
2649 "visit_details" => "NodeContext ctx, bool isOpen",
2650 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2651 "NodeContext ctx, string output"
2652 }
2653 "visit_list_start" => "NodeContext ctx, bool ordered",
2654 "visit_list_end" => "NodeContext ctx, bool ordered, string output",
2655 "visit_element_start"
2656 | "visit_table_start"
2657 | "visit_definition_list_start"
2658 | "visit_figure_start"
2659 | "visit_line_break"
2660 | "visit_horizontal_rule" => "NodeContext ctx",
2661 _ => "NodeContext ctx",
2662 };
2663
2664 let (action_type, action_value) = match action {
2665 CallbackAction::Skip => ("skip", String::new()),
2666 CallbackAction::Continue => ("continue", String::new()),
2667 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
2668 CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
2669 CallbackAction::CustomTemplate { template } => {
2670 let camel = snake_case_template_to_camel(template);
2671 ("custom_template", escape_csharp(&camel))
2672 }
2673 };
2674
2675 let rendered = crate::template_env::render(
2676 "csharp/visitor_method.jinja",
2677 minijinja::context! {
2678 camel_method => camel_method,
2679 params => params,
2680 action_type => action_type,
2681 action_value => action_value,
2682 },
2683 );
2684 let _ = write!(decl, "{}", rendered);
2685}
2686
2687fn method_to_camel(snake: &str) -> String {
2689 use heck::ToUpperCamelCase;
2690 snake.to_upper_camel_case()
2691}
2692
2693fn snake_case_template_to_camel(template: &str) -> String {
2696 use heck::ToLowerCamelCase;
2697 let mut out = String::with_capacity(template.len());
2698 let mut chars = template.chars().peekable();
2699 while let Some(c) = chars.next() {
2700 if c == '{' {
2701 let mut name = String::new();
2702 while let Some(&nc) = chars.peek() {
2703 if nc == '}' {
2704 chars.next();
2705 break;
2706 }
2707 name.push(nc);
2708 chars.next();
2709 }
2710 out.push('{');
2711 out.push_str(&name.to_lower_camel_case());
2712 out.push('}');
2713 } else {
2714 out.push(c);
2715 }
2716 }
2717 out
2718}
2719
2720fn build_csharp_method_call(
2725 result_var: &str,
2726 method_name: &str,
2727 args: Option<&serde_json::Value>,
2728 class_name: &str,
2729) -> String {
2730 match method_name {
2731 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
2732 "root_node_type" => format!("{result_var}.RootNode.Kind"),
2733 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
2734 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
2735 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
2736 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
2737 "contains_node_type" => {
2738 let node_type = args
2739 .and_then(|a| a.get("node_type"))
2740 .and_then(|v| v.as_str())
2741 .unwrap_or("");
2742 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
2743 }
2744 "find_nodes_by_type" => {
2745 let node_type = args
2746 .and_then(|a| a.get("node_type"))
2747 .and_then(|v| v.as_str())
2748 .unwrap_or("");
2749 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
2750 }
2751 "run_query" => {
2752 let query_source = args
2753 .and_then(|a| a.get("query_source"))
2754 .and_then(|v| v.as_str())
2755 .unwrap_or("");
2756 let language = args
2757 .and_then(|a| a.get("language"))
2758 .and_then(|v| v.as_str())
2759 .unwrap_or("");
2760 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
2761 }
2762 _ => {
2763 use heck::ToUpperCamelCase;
2764 let pascal = method_name.to_upper_camel_case();
2765 format!("{result_var}.{pascal}()")
2766 }
2767 }
2768}
2769
2770fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
2771 if fixture.is_http_test() {
2773 return false;
2774 }
2775 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
2776 let cs_override = call_config
2777 .overrides
2778 .get("csharp")
2779 .or_else(|| e2e_config.call.overrides.get("csharp"));
2780 if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
2782 return true;
2783 }
2784 cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
2787}
2788
2789fn classify_bytes_value_csharp(s: &str) -> String {
2792 if let Some(first) = s.chars().next() {
2795 if first.is_ascii_alphanumeric() || first == '_' {
2796 if let Some(slash_pos) = s.find('/') {
2797 if slash_pos > 0 {
2798 let after_slash = &s[slash_pos + 1..];
2799 if after_slash.contains('.') && !after_slash.is_empty() {
2800 return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
2802 }
2803 }
2804 }
2805 }
2806 }
2807
2808 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
2811 return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
2813 }
2814
2815 format!("System.Convert.FromBase64String(\"{}\")", s)
2819}