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 _type_defs: &[alef_core::ir::TypeDef],
34 ) -> Result<Vec<GeneratedFile>> {
35 let lang = self.language_name();
36 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38 let mut files = Vec::new();
39
40 let call = &e2e_config.call;
42 let overrides = call.overrides.get(lang);
43 let function_name = overrides
44 .and_then(|o| o.function.as_ref())
45 .cloned()
46 .unwrap_or_else(|| call.function.to_upper_camel_case());
47 let class_name = overrides
48 .and_then(|o| o.class.as_ref())
49 .cloned()
50 .unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
51 let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
53 let namespace = overrides
54 .and_then(|o| o.module.as_ref())
55 .cloned()
56 .or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
57 .unwrap_or_else(|| {
58 if call.module.is_empty() {
59 "Kreuzberg".to_string()
60 } else {
61 call.module.to_upper_camel_case()
62 }
63 });
64 let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
65 let result_var = &call.result_var;
66 let is_async = call.r#async;
67
68 let cs_pkg = e2e_config.resolve_package("csharp");
70 let pkg_name = cs_pkg
71 .as_ref()
72 .and_then(|p| p.name.as_ref())
73 .cloned()
74 .unwrap_or_else(|| config.name.to_upper_camel_case());
75 let pkg_path = cs_pkg
77 .as_ref()
78 .and_then(|p| p.path.as_ref())
79 .cloned()
80 .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
81 let pkg_version = cs_pkg
82 .as_ref()
83 .and_then(|p| p.version.as_ref())
84 .cloned()
85 .or_else(|| config.resolved_version())
86 .unwrap_or_else(|| "0.1.0".to_string());
87
88 let csproj_name = format!("{pkg_name}.E2eTests.csproj");
91 files.push(GeneratedFile {
92 path: output_base.join(&csproj_name),
93 content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
94 generated_header: false,
95 });
96
97 let needs_mock_server = groups
105 .iter()
106 .flat_map(|g| g.fixtures.iter())
107 .any(|f| f.needs_mock_server());
108
109 files.push(GeneratedFile {
114 path: output_base.join("TestSetup.cs"),
115 content: render_test_setup(needs_mock_server),
116 generated_header: true,
117 });
118
119 let tests_base = output_base.join("tests");
121 let field_resolver = FieldResolver::new(
122 &e2e_config.fields,
123 &e2e_config.fields_optional,
124 &e2e_config.result_fields,
125 &e2e_config.fields_array,
126 &std::collections::HashSet::new(),
127 );
128
129 static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
131 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
132
133 let mut effective_nested_types = default_csharp_nested_types();
135 if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
136 effective_nested_types.extend(overrides_map.clone());
137 }
138
139 for group in groups {
140 let active: Vec<&Fixture> = group
141 .fixtures
142 .iter()
143 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
144 .collect();
145
146 if active.is_empty() {
147 continue;
148 }
149
150 let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
151 let filename = format!("{test_class}.cs");
152 let content = render_test_file(
153 &group.category,
154 &active,
155 &namespace,
156 &class_name,
157 &function_name,
158 &exception_class,
159 result_var,
160 &test_class,
161 &e2e_config.call.args,
162 &field_resolver,
163 result_is_simple,
164 is_async,
165 e2e_config,
166 enum_fields,
167 &effective_nested_types,
168 );
169 files.push(GeneratedFile {
170 path: tests_base.join(filename),
171 content,
172 generated_header: true,
173 });
174 }
175
176 Ok(files)
177 }
178
179 fn language_name(&self) -> &'static str {
180 "csharp"
181 }
182}
183
184fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
189 let pkg_ref = match dep_mode {
190 crate::config::DependencyMode::Registry => {
191 format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
192 }
193 crate::config::DependencyMode::Local => {
194 format!(" <ProjectReference Include=\"{pkg_path}\" />")
195 }
196 };
197 crate::template_env::render(
198 "csharp/csproj.jinja",
199 minijinja::context! {
200 pkg_ref => pkg_ref,
201 microsoft_net_test_sdk_version => tv::nuget::MICROSOFT_NET_TEST_SDK,
202 xunit_version => tv::nuget::XUNIT,
203 xunit_runner_version => tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
204 },
205 )
206}
207
208fn render_test_setup(needs_mock_server: bool) -> String {
209 let mut out = String::new();
210 out.push_str(&hash::header(CommentStyle::DoubleSlash));
211 out.push_str("using System;\n");
212 out.push_str("using System.IO;\n");
213 if needs_mock_server {
214 out.push_str("using System.Diagnostics;\n");
215 }
216 out.push_str("using System.Runtime.CompilerServices;\n\n");
217 out.push_str("namespace Kreuzberg.E2eTests;\n\n");
218 out.push_str("internal static class TestSetup\n");
219 out.push_str("{\n");
220 if needs_mock_server {
221 out.push_str(" private static Process? _mockServer;\n\n");
222 }
223 out.push_str(" [ModuleInitializer]\n");
224 out.push_str(" internal static void Init()\n");
225 out.push_str(" {\n");
226 out.push_str(" // Walk up from the assembly directory until we find the repo root\n");
227 out.push_str(" // (the directory containing test_documents/) so that fixture paths\n");
228 out.push_str(" // like \"docx/fake.docx\" resolve regardless of where dotnet test\n");
229 out.push_str(" // launched the runner from.\n");
230 out.push_str(" var dir = new DirectoryInfo(AppContext.BaseDirectory);\n");
231 out.push_str(" DirectoryInfo? repoRoot = null;\n");
232 out.push_str(" while (dir != null)\n");
233 out.push_str(" {\n");
234 out.push_str(" var candidate = Path.Combine(dir.FullName, \"test_documents\");\n");
235 out.push_str(" if (Directory.Exists(candidate))\n");
236 out.push_str(" {\n");
237 out.push_str(" repoRoot = dir;\n");
238 out.push_str(" Directory.SetCurrentDirectory(candidate);\n");
239 out.push_str(" break;\n");
240 out.push_str(" }\n");
241 out.push_str(" dir = dir.Parent;\n");
242 out.push_str(" }\n");
243 if needs_mock_server {
244 out.push('\n');
245 out.push_str(" // Spawn the mock-server binary before any test loads, mirroring the\n");
246 out.push_str(" // Ruby spec_helper / Python conftest pattern. Honors a pre-set\n");
247 out.push_str(" // MOCK_SERVER_URL (e.g. set by `task` or CI) by skipping the spawn.\n");
248 out.push_str(" // Without this, every fixture-bound test failed with\n");
249 out.push_str(" // `<Lib>Exception : builder error` because reqwest rejected the\n");
250 out.push_str(" // relative URL produced by `\"\" + \"/fixtures/<id>\"`.\n");
251 out.push_str(" var preset = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\");\n");
252 out.push_str(" if (!string.IsNullOrEmpty(preset))\n");
253 out.push_str(" {\n");
254 out.push_str(" return;\n");
255 out.push_str(" }\n");
256 out.push_str(" if (repoRoot == null)\n");
257 out.push_str(" {\n");
258 out.push_str(" throw new InvalidOperationException(\"TestSetup: could not locate repo root (test_documents/ not found)\");\n");
259 out.push_str(" }\n");
260 out.push_str(" var bin = Path.Combine(\n");
261 out.push_str(" repoRoot.FullName,\n");
262 out.push_str(" \"e2e\", \"rust\", \"target\", \"release\", \"mock-server\");\n");
263 out.push_str(" if (OperatingSystem.IsWindows())\n");
264 out.push_str(" {\n");
265 out.push_str(" bin += \".exe\";\n");
266 out.push_str(" }\n");
267 out.push_str(" var fixturesDir = Path.Combine(repoRoot.FullName, \"fixtures\");\n");
268 out.push_str(" if (!File.Exists(bin))\n");
269 out.push_str(" {\n");
270 out.push_str(" throw new InvalidOperationException(\n");
271 out.push_str(" $\"TestSetup: mock-server binary not found at {bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release\");\n");
272 out.push_str(" }\n");
273 out.push_str(" var psi = new ProcessStartInfo\n");
274 out.push_str(" {\n");
275 out.push_str(" FileName = bin,\n");
276 out.push_str(" Arguments = $\"\\\"{fixturesDir}\\\"\",\n");
277 out.push_str(" RedirectStandardInput = true,\n");
278 out.push_str(" RedirectStandardOutput = true,\n");
279 out.push_str(" RedirectStandardError = true,\n");
280 out.push_str(" UseShellExecute = false,\n");
281 out.push_str(" };\n");
282 out.push_str(" _mockServer = Process.Start(psi)\n");
283 out.push_str(
284 " ?? throw new InvalidOperationException(\"TestSetup: failed to start mock-server\");\n",
285 );
286 out.push_str(" // The mock-server prints `MOCK_SERVER_URL=<url>` as its first stdout\n");
287 out.push_str(" // line, then `mock-server: loaded <N> routes from <dir>` etc. Read\n");
288 out.push_str(" // until we see the URL line so it can race against startup.\n");
289 out.push_str(" string? url = null;\n");
290 out.push_str(" for (int i = 0; i < 16; i++)\n");
291 out.push_str(" {\n");
292 out.push_str(" var line = _mockServer.StandardOutput.ReadLine();\n");
293 out.push_str(" if (line == null)\n");
294 out.push_str(" {\n");
295 out.push_str(" break;\n");
296 out.push_str(" }\n");
297 out.push_str(" const string prefix = \"MOCK_SERVER_URL=\";\n");
298 out.push_str(" if (line.StartsWith(prefix, StringComparison.Ordinal))\n");
299 out.push_str(" {\n");
300 out.push_str(" url = line.Substring(prefix.Length).Trim();\n");
301 out.push_str(" break;\n");
302 out.push_str(" }\n");
303 out.push_str(" }\n");
304 out.push_str(" if (string.IsNullOrEmpty(url))\n");
305 out.push_str(" {\n");
306 out.push_str(" try { _mockServer.Kill(true); } catch { }\n");
307 out.push_str(" throw new InvalidOperationException(\"TestSetup: mock-server did not emit MOCK_SERVER_URL\");\n");
308 out.push_str(" }\n");
309 out.push_str(" Environment.SetEnvironmentVariable(\"MOCK_SERVER_URL\", url);\n");
310 out.push_str(" // TCP-readiness probe: ensure axum::serve is accepting before tests start.\n");
311 out.push_str(" // The mock-server binds the TcpListener synchronously then prints the URL\n");
312 out.push_str(" // before tokio::spawn(axum::serve(...)) is polled, so under xUnit\n");
313 out.push_str(" // class-parallel default tests can race startup. Poll-connect (max 5s,\n");
314 out.push_str(" // 50ms backoff) until success.\n");
315 out.push_str(" var healthUri = new System.Uri(url);\n");
316 out.push_str(" var deadline = System.Diagnostics.Stopwatch.StartNew();\n");
317 out.push_str(" while (deadline.ElapsedMilliseconds < 5000)\n");
318 out.push_str(" {\n");
319 out.push_str(" try\n");
320 out.push_str(" {\n");
321 out.push_str(" using var probe = new System.Net.Sockets.TcpClient();\n");
322 out.push_str(" var task = probe.ConnectAsync(healthUri.Host, healthUri.Port);\n");
323 out.push_str(" if (task.Wait(100) && probe.Connected) { break; }\n");
324 out.push_str(" }\n");
325 out.push_str(" catch (System.Exception) { }\n");
326 out.push_str(" System.Threading.Thread.Sleep(50);\n");
327 out.push_str(" }\n");
328 out.push_str(" // Drain stdout/stderr so the child does not block on a full pipe.\n");
329 out.push_str(" var server = _mockServer;\n");
330 out.push_str(" var stdoutThread = new System.Threading.Thread(() =>\n");
331 out.push_str(" {\n");
332 out.push_str(" try { server.StandardOutput.ReadToEnd(); } catch { }\n");
333 out.push_str(" }) { IsBackground = true };\n");
334 out.push_str(" stdoutThread.Start();\n");
335 out.push_str(" var stderrThread = new System.Threading.Thread(() =>\n");
336 out.push_str(" {\n");
337 out.push_str(" try { server.StandardError.ReadToEnd(); } catch { }\n");
338 out.push_str(" }) { IsBackground = true };\n");
339 out.push_str(" stderrThread.Start();\n");
340 out.push_str(" // Tear the child down on assembly unload / process exit by closing\n");
341 out.push_str(" // its stdin (the mock-server treats stdin EOF as a shutdown signal).\n");
342 out.push_str(" AppDomain.CurrentDomain.ProcessExit += (_, _) =>\n");
343 out.push_str(" {\n");
344 out.push_str(" try { _mockServer.StandardInput.Close(); } catch { }\n");
345 out.push_str(" try { if (!_mockServer.WaitForExit(2000)) { _mockServer.Kill(true); } } catch { }\n");
346 out.push_str(" };\n");
347 }
348 out.push_str(" }\n");
349 out.push_str("}\n");
350 out
351}
352
353#[allow(clippy::too_many_arguments)]
354fn render_test_file(
355 category: &str,
356 fixtures: &[&Fixture],
357 namespace: &str,
358 class_name: &str,
359 function_name: &str,
360 exception_class: &str,
361 result_var: &str,
362 test_class: &str,
363 args: &[crate::config::ArgMapping],
364 field_resolver: &FieldResolver,
365 result_is_simple: bool,
366 is_async: bool,
367 e2e_config: &E2eConfig,
368 enum_fields: &HashMap<String, String>,
369 nested_types: &HashMap<String, String>,
370) -> String {
371 let mut using_imports = String::new();
373 using_imports.push_str("using System;\n");
374 using_imports.push_str("using System.Collections.Generic;\n");
375 using_imports.push_str("using System.Linq;\n");
376 using_imports.push_str("using System.Net.Http;\n");
377 using_imports.push_str("using System.Text;\n");
378 using_imports.push_str("using System.Text.Json;\n");
379 using_imports.push_str("using System.Text.Json.Serialization;\n");
380 using_imports.push_str("using System.Threading.Tasks;\n");
381 using_imports.push_str("using Xunit;\n");
382 using_imports.push_str(&format!("using {namespace};\n"));
383 using_imports.push_str(&format!("using static {namespace}.{class_name};\n"));
384
385 let config_options_field = " private static readonly JsonSerializerOptions ConfigOptions = new() { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault };";
387
388 let mut visitor_class_decls: Vec<String> = Vec::new();
392
393 let mut fixtures_body = String::new();
395 for (i, fixture) in fixtures.iter().enumerate() {
396 render_test_method(
397 &mut fixtures_body,
398 &mut visitor_class_decls,
399 fixture,
400 class_name,
401 function_name,
402 exception_class,
403 result_var,
404 args,
405 field_resolver,
406 result_is_simple,
407 is_async,
408 e2e_config,
409 enum_fields,
410 nested_types,
411 );
412 if i + 1 < fixtures.len() {
413 fixtures_body.push('\n');
414 }
415 }
416
417 let mut visitor_classes_str = String::new();
419 for (i, decl) in visitor_class_decls.iter().enumerate() {
420 if i > 0 {
421 visitor_classes_str.push('\n');
422 }
423 visitor_classes_str.push('\n');
424 for line in decl.lines() {
426 visitor_classes_str.push_str(" ");
427 visitor_classes_str.push_str(line);
428 visitor_classes_str.push('\n');
429 }
430 }
431
432 let ctx = minijinja::context! {
433 header => hash::header(CommentStyle::DoubleSlash),
434 using_imports => using_imports,
435 category => category,
436 namespace => namespace,
437 test_class => test_class,
438 config_options_field => config_options_field,
439 fixtures_body => fixtures_body,
440 visitor_class_decls => visitor_classes_str,
441 };
442
443 crate::template_env::render("csharp/test_file.jinja", ctx)
444}
445
446struct CSharpTestClientRenderer;
455
456fn to_csharp_http_method(method: &str) -> String {
458 let lower = method.to_ascii_lowercase();
459 let mut chars = lower.chars();
460 match chars.next() {
461 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
462 None => String::new(),
463 }
464}
465
466const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
470 "content-length",
471 "host",
472 "connection",
473 "expect",
474 "transfer-encoding",
475 "upgrade",
476 "content-type",
479 "content-encoding",
481 "content-language",
482 "content-location",
483 "content-md5",
484 "content-range",
485 "content-disposition",
486];
487
488fn is_csharp_content_header(name: &str) -> bool {
492 matches!(
493 name.to_ascii_lowercase().as_str(),
494 "content-type"
495 | "content-length"
496 | "content-encoding"
497 | "content-language"
498 | "content-location"
499 | "content-md5"
500 | "content-range"
501 | "content-disposition"
502 | "expires"
503 | "last-modified"
504 | "allow"
505 )
506}
507
508impl client::TestClientRenderer for CSharpTestClientRenderer {
509 fn language_name(&self) -> &'static str {
510 "csharp"
511 }
512
513 fn sanitize_test_name(&self, id: &str) -> String {
515 id.to_upper_camel_case()
516 }
517
518 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
521 let escaped_reason = skip_reason.map(escape_csharp);
522 let rendered = crate::template_env::render(
523 "csharp/http_test_open.jinja",
524 minijinja::context! {
525 fn_name => fn_name,
526 description => description,
527 skip_reason => escaped_reason,
528 },
529 );
530 out.push_str(&rendered);
531 }
532
533 fn render_test_close(&self, out: &mut String) {
535 let rendered = crate::template_env::render("csharp/http_test_close.jinja", minijinja::context! {});
536 out.push_str(&rendered);
537 }
538
539 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
544 let method = to_csharp_http_method(ctx.method);
545 let path = escape_csharp(ctx.path);
546
547 out.push_str(" var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";\n");
548 out.push_str(
551 " using var handler = new System.Net.Http.HttpClientHandler { AllowAutoRedirect = false };\n",
552 );
553 out.push_str(" using var client = new System.Net.Http.HttpClient(handler);\n");
554 out.push_str(&format!(" var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");\n"));
555
556 if let Some(body) = ctx.body {
558 let content_type = ctx.content_type.unwrap_or("application/json");
559 let json_str = serde_json::to_string(body).unwrap_or_default();
560 let escaped = escape_csharp(&json_str);
561 out.push_str(&format!(" request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");\n"));
562 }
563
564 for (name, value) in ctx.headers {
566 if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
567 continue;
568 }
569 let escaped_name = escape_csharp(name);
570 let escaped_value = escape_csharp(value);
571 out.push_str(&format!(
572 " request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");\n"
573 ));
574 }
575
576 if !ctx.cookies.is_empty() {
578 let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
579 pairs.sort();
580 let cookie_header = escape_csharp(&pairs.join("; "));
581 out.push_str(&format!(
582 " request.Headers.Add(\"Cookie\", \"{cookie_header}\");\n"
583 ));
584 }
585
586 out.push_str(" var response = await client.SendAsync(request);\n");
587 }
588
589 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
591 out.push_str(&format!(" Assert.Equal({status}, (int)response.StatusCode);\n"));
592 }
593
594 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
599 let target = if is_csharp_content_header(name) {
600 "response.Content.Headers"
601 } else {
602 "response.Headers"
603 };
604 let escaped_name = escape_csharp(name);
605 match expected {
606 "<<present>>" => {
607 out.push_str(&format!(" Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");\n"));
608 }
609 "<<absent>>" => {
610 out.push_str(&format!(" Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");\n"));
611 }
612 "<<uuid>>" => {
613 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"));
615 }
616 literal => {
617 let var_name = format!("hdr{}", sanitize_ident(name));
620 let escaped_value = escape_csharp(literal);
621 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"));
622 }
623 }
624 }
625
626 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
630 match expected {
631 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
632 let json_str = serde_json::to_string(expected).unwrap_or_default();
633 let escaped = escape_csharp(&json_str);
634 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
635 out.push_str(" var body = JsonDocument.Parse(bodyText).RootElement;\n");
636 out.push_str(&format!(
637 " var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;\n"
638 ));
639 out.push_str(" Assert.Equal(expectedBody.GetRawText(), body.GetRawText());\n");
640 }
641 serde_json::Value::String(s) => {
642 let escaped = escape_csharp(s);
643 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
644 out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
645 }
646 other => {
647 let escaped = escape_csharp(&other.to_string());
648 out.push_str(" var bodyText = await response.Content.ReadAsStringAsync();\n");
649 out.push_str(&format!(" Assert.Equal(\"{escaped}\", bodyText.Trim());\n"));
650 }
651 }
652 }
653
654 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
659 if let Some(obj) = expected.as_object() {
660 out.push_str(" var partialBodyText = await response.Content.ReadAsStringAsync();\n");
661 out.push_str(" var partialBody = JsonDocument.Parse(partialBodyText).RootElement;\n");
662 for (key, val) in obj {
663 let escaped_key = escape_csharp(key);
664 let json_str = serde_json::to_string(val).unwrap_or_default();
665 let escaped_val = escape_csharp(&json_str);
666 let var_name = format!("expected{}", key.to_upper_camel_case());
667 out.push_str(&format!(
668 " var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;\n"
669 ));
670 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"));
671 }
672 }
673 }
674
675 fn render_assert_validation_errors(
678 &self,
679 out: &mut String,
680 _response_var: &str,
681 errors: &[ValidationErrorExpectation],
682 ) {
683 out.push_str(" var validationBodyText = await response.Content.ReadAsStringAsync();\n");
684 for err in errors {
685 let escaped_msg = escape_csharp(&err.msg);
686 out.push_str(&format!(
687 " Assert.Contains(\"{escaped_msg}\", validationBodyText);\n"
688 ));
689 }
690 }
691}
692
693fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
696 client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
697}
698
699#[allow(clippy::too_many_arguments)]
700fn render_test_method(
701 out: &mut String,
702 visitor_class_decls: &mut Vec<String>,
703 fixture: &Fixture,
704 class_name: &str,
705 _function_name: &str,
706 exception_class: &str,
707 _result_var: &str,
708 _args: &[crate::config::ArgMapping],
709 field_resolver: &FieldResolver,
710 result_is_simple: bool,
711 _is_async: bool,
712 e2e_config: &E2eConfig,
713 enum_fields: &HashMap<String, String>,
714 nested_types: &HashMap<String, String>,
715) {
716 let method_name = fixture.id.to_upper_camel_case();
717 let description = &fixture.description;
718
719 if let Some(http) = &fixture.http {
721 render_http_test_method(out, fixture, http);
722 return;
723 }
724
725 if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
728 let skip_reason =
729 "non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function";
730 let ctx = minijinja::context! {
731 is_skipped => true,
732 skip_reason => skip_reason,
733 description => description,
734 method_name => method_name,
735 };
736 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
737 out.push_str(&rendered);
738 return;
739 }
740
741 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
742
743 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
746 let lang = "csharp";
747 let cs_overrides = call_config.overrides.get(lang);
748
749 let raw_function_name = cs_overrides
754 .and_then(|o| o.function.as_ref())
755 .cloned()
756 .unwrap_or_else(|| call_config.function.clone());
757 if raw_function_name == "chat_stream" {
758 render_chat_stream_test_method(
759 out,
760 fixture,
761 class_name,
762 call_config,
763 cs_overrides,
764 e2e_config,
765 enum_fields,
766 nested_types,
767 exception_class,
768 );
769 return;
770 }
771
772 let effective_function_name = cs_overrides
773 .and_then(|o| o.function.as_ref())
774 .cloned()
775 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
776 let effective_result_var = &call_config.result_var;
777 let effective_is_async = call_config.r#async;
778 let function_name = effective_function_name.as_str();
779 let result_var = effective_result_var.as_str();
780 let is_async = effective_is_async;
781 let args = call_config.args.as_slice();
782
783 let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
787 let per_call_result_is_bytes = call_config.result_is_bytes || cs_overrides.is_some_and(|o| o.result_is_bytes);
792 let effective_result_is_simple = result_is_simple || per_call_result_is_simple || per_call_result_is_bytes;
793 let effective_result_is_bytes = per_call_result_is_bytes;
794 let returns_void = call_config.returns_void;
795 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
796 let top_level_options_type = e2e_config
798 .call
799 .overrides
800 .get("csharp")
801 .and_then(|o| o.options_type.as_deref());
802 let effective_options_type = cs_overrides
803 .and_then(|o| o.options_type.as_deref())
804 .or(top_level_options_type);
805
806 let top_level_options_via = e2e_config
813 .call
814 .overrides
815 .get("csharp")
816 .and_then(|o| o.options_via.as_deref());
817 let effective_options_via = cs_overrides
818 .and_then(|o| o.options_via.as_deref())
819 .or(top_level_options_via);
820
821 let (mut setup_lines, args_str) = build_args_and_setup(
822 &fixture.input,
823 args,
824 class_name,
825 effective_options_type,
826 effective_options_via,
827 enum_fields,
828 nested_types,
829 &fixture.id,
830 );
831
832 let mut visitor_arg = String::new();
834 let has_visitor = fixture.visitor.is_some();
835 if let Some(visitor_spec) = &fixture.visitor {
836 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
837 }
838
839 let final_args = if has_visitor && !visitor_arg.is_empty() {
843 let opts_type = effective_options_type.unwrap_or("ConversionOptions");
844 if args_str.contains("JsonSerializer.Deserialize") {
845 setup_lines.push(format!("var options = {args_str};"));
847 setup_lines.push(format!("options.Visitor = {visitor_arg};"));
848 "options".to_string()
849 } else if args_str.ends_with(", null") {
850 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
852 let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
854 } else if args_str.contains(", null,") {
855 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
857 args_str.replace(", null,", ", options,")
858 } else if args_str.is_empty() {
859 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
861 "options".to_string()
862 } else {
863 setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
865 format!("{args_str}, options")
866 }
867 } else if extra_args_slice.is_empty() {
868 args_str
869 } else if args_str.is_empty() {
870 extra_args_slice.join(", ")
871 } else {
872 format!("{args_str}, {}", extra_args_slice.join(", "))
873 };
874
875 let effective_function_name = function_name.to_string();
878
879 let return_type = if is_async { "async Task" } else { "void" };
880 let await_kw = if is_async { "await " } else { "" };
881
882 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
885 e2e_config
886 .call
887 .overrides
888 .get("csharp")
889 .and_then(|o| o.client_factory.as_deref())
890 });
891 let call_target = if client_factory.is_some() {
892 "client".to_string()
893 } else {
894 class_name.to_string()
895 };
896
897 let mut client_factory_setup = String::new();
904 if let Some(factory) = client_factory {
905 let factory_name = factory.to_upper_camel_case();
906 let fixture_id = &fixture.id;
907 let is_live_smoke = fixture.mock_response.is_none()
908 && fixture.http.is_none()
909 && fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()).is_some();
910 if is_live_smoke {
911 let api_key_var = fixture
912 .env
913 .as_ref()
914 .and_then(|e| e.api_key_var.as_deref())
915 .unwrap_or("");
916 client_factory_setup.push_str(&format!(
917 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
918 ));
919 client_factory_setup.push_str(" if (string.IsNullOrEmpty(apiKey)) { return; }\n");
920 client_factory_setup.push_str(&format!(
921 " var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
922 ));
923 } else {
924 client_factory_setup.push_str(&format!(" var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"));
925 client_factory_setup.push_str(&format!(
926 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
927 ));
928 }
929 }
930
931 let call_expr = format!("{}({})", effective_function_name, final_args);
933
934 let mut effective_enum_fields: std::collections::HashSet<String> = e2e_config.fields_enum.clone();
942 for k in enum_fields.keys() {
943 effective_enum_fields.insert(k.clone());
944 }
945 if let Some(o) = cs_overrides {
946 for k in o.enum_fields.keys() {
947 effective_enum_fields.insert(k.clone());
948 }
949 }
950
951 let mut assertions_body = String::new();
953 if !expects_error && !returns_void {
954 for assertion in &fixture.assertions {
955 render_assertion(
956 &mut assertions_body,
957 assertion,
958 result_var,
959 class_name,
960 exception_class,
961 field_resolver,
962 effective_result_is_simple,
963 call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec),
964 call_config.result_is_array,
965 effective_result_is_bytes,
966 &effective_enum_fields,
967 );
968 }
969 }
970
971 let ctx = minijinja::context! {
972 is_skipped => false,
973 expects_error => expects_error,
974 description => description,
975 return_type => return_type,
976 method_name => method_name,
977 async_kw => await_kw,
978 call_target => call_target,
979 setup_lines => setup_lines.clone(),
980 call_expr => call_expr,
981 exception_class => exception_class,
982 client_factory_setup => client_factory_setup,
983 has_usable_assertion => !expects_error && !returns_void,
984 result_var => result_var,
985 assertions_body => assertions_body,
986 };
987
988 let rendered = crate::template_env::render("csharp/test_method.jinja", ctx);
989 for line in rendered.lines() {
991 out.push_str(" ");
992 out.push_str(line);
993 out.push('\n');
994 }
995}
996
997#[allow(clippy::too_many_arguments)]
1005fn render_chat_stream_test_method(
1006 out: &mut String,
1007 fixture: &Fixture,
1008 class_name: &str,
1009 call_config: &crate::config::CallConfig,
1010 cs_overrides: Option<&crate::config::CallOverride>,
1011 e2e_config: &E2eConfig,
1012 enum_fields: &HashMap<String, String>,
1013 nested_types: &HashMap<String, String>,
1014 exception_class: &str,
1015) {
1016 let method_name = fixture.id.to_upper_camel_case();
1017 let description = &fixture.description;
1018 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
1019
1020 let effective_function_name = cs_overrides
1021 .and_then(|o| o.function.as_ref())
1022 .cloned()
1023 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
1024 let function_name = effective_function_name.as_str();
1025 let args = call_config.args.as_slice();
1026
1027 let top_level_options_type = e2e_config
1028 .call
1029 .overrides
1030 .get("csharp")
1031 .and_then(|o| o.options_type.as_deref());
1032 let effective_options_type = cs_overrides
1033 .and_then(|o| o.options_type.as_deref())
1034 .or(top_level_options_type);
1035 let top_level_options_via = e2e_config
1036 .call
1037 .overrides
1038 .get("csharp")
1039 .and_then(|o| o.options_via.as_deref());
1040 let effective_options_via = cs_overrides
1041 .and_then(|o| o.options_via.as_deref())
1042 .or(top_level_options_via);
1043
1044 let (setup_lines, args_str) = build_args_and_setup(
1045 &fixture.input,
1046 args,
1047 class_name,
1048 effective_options_type,
1049 effective_options_via,
1050 enum_fields,
1051 nested_types,
1052 &fixture.id,
1053 );
1054
1055 let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
1056 e2e_config
1057 .call
1058 .overrides
1059 .get("csharp")
1060 .and_then(|o| o.client_factory.as_deref())
1061 });
1062 let mut client_factory_setup = String::new();
1063 if let Some(factory) = client_factory {
1064 let factory_name = factory.to_upper_camel_case();
1065 let fixture_id = &fixture.id;
1066 let is_live_smoke = fixture.mock_response.is_none()
1067 && fixture.http.is_none()
1068 && fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()).is_some();
1069 if is_live_smoke {
1070 let api_key_var = fixture
1071 .env
1072 .as_ref()
1073 .and_then(|e| e.api_key_var.as_deref())
1074 .unwrap_or("");
1075 client_factory_setup.push_str(&format!(
1076 " var apiKey = System.Environment.GetEnvironmentVariable(\"{api_key_var}\");\n"
1077 ));
1078 client_factory_setup.push_str(" if (string.IsNullOrEmpty(apiKey)) { return; }\n");
1079 client_factory_setup.push_str(&format!(
1080 " var client = {class_name}.{factory_name}(apiKey, null, null, null, null);\n"
1081 ));
1082 } else {
1083 client_factory_setup.push_str(&format!(
1084 " var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";\n"
1085 ));
1086 client_factory_setup.push_str(&format!(
1087 " var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);\n"
1088 ));
1089 }
1090 }
1091
1092 let call_target = if client_factory.is_some() { "client" } else { class_name };
1093 let call_expr = format!("{call_target}.{function_name}({args_str})");
1094
1095 let mut needs_finish_reason = false;
1097 let mut needs_tool_calls_json = false;
1098 let mut needs_tool_calls_0_function_name = false;
1099 let mut needs_total_tokens = false;
1100 for a in &fixture.assertions {
1101 if let Some(f) = a.field.as_deref() {
1102 match f {
1103 "finish_reason" => needs_finish_reason = true,
1104 "tool_calls" => needs_tool_calls_json = true,
1105 "tool_calls[0].function.name" => needs_tool_calls_0_function_name = true,
1106 "usage.total_tokens" => needs_total_tokens = true,
1107 _ => {}
1108 }
1109 }
1110 }
1111
1112 let mut body = String::new();
1113 let _ = writeln!(body, " [Fact]");
1114 let _ = writeln!(body, " public async Task Test_{method_name}()");
1115 let _ = writeln!(body, " {{");
1116 let _ = writeln!(body, " // {description}");
1117 if !client_factory_setup.is_empty() {
1118 body.push_str(&client_factory_setup);
1119 }
1120 for line in &setup_lines {
1121 let _ = writeln!(body, " {line}");
1122 }
1123
1124 if expects_error {
1125 let _ = writeln!(
1128 body,
1129 " await Assert.ThrowsAnyAsync<{exception_class}>(async () => {{"
1130 );
1131 let _ = writeln!(body, " await foreach (var _chunk in {call_expr}) {{ }}");
1132 body.push_str(" });\n");
1133 body.push_str(" }\n");
1134 for line in body.lines() {
1135 out.push_str(" ");
1136 out.push_str(line);
1137 out.push('\n');
1138 }
1139 return;
1140 }
1141
1142 body.push_str(" var chunks = new List<ChatCompletionChunk>();\n");
1143 body.push_str(" var streamContent = new System.Text.StringBuilder();\n");
1144 body.push_str(" var streamComplete = false;\n");
1145 if needs_finish_reason {
1146 body.push_str(" string? lastFinishReason = null;\n");
1147 }
1148 if needs_tool_calls_json {
1149 body.push_str(" string? toolCallsJson = null;\n");
1150 }
1151 if needs_tool_calls_0_function_name {
1152 body.push_str(" string? toolCalls0FunctionName = null;\n");
1153 }
1154 if needs_total_tokens {
1155 body.push_str(" long? totalTokens = null;\n");
1156 }
1157 let _ = writeln!(body, " await foreach (var chunk in {call_expr})");
1158 body.push_str(" {\n");
1159 body.push_str(" chunks.Add(chunk);\n");
1160 body.push_str(
1161 " var choice = chunk.Choices != null && chunk.Choices.Count > 0 ? chunk.Choices[0] : null;\n",
1162 );
1163 body.push_str(" if (choice != null)\n");
1164 body.push_str(" {\n");
1165 body.push_str(" var delta = choice.Delta;\n");
1166 body.push_str(" if (delta != null && !string.IsNullOrEmpty(delta.Content))\n");
1167 body.push_str(" {\n");
1168 body.push_str(" streamContent.Append(delta.Content);\n");
1169 body.push_str(" }\n");
1170 if needs_finish_reason {
1171 body.push_str(" if (choice.FinishReason != null)\n");
1172 body.push_str(" {\n");
1173 body.push_str(" lastFinishReason = choice.FinishReason?.ToString()?.ToLower();\n");
1174 body.push_str(" }\n");
1175 }
1176 if needs_tool_calls_json || needs_tool_calls_0_function_name {
1177 body.push_str(" var tcs = delta?.ToolCalls;\n");
1178 body.push_str(" if (tcs != null && tcs.Count > 0)\n");
1179 body.push_str(" {\n");
1180 if needs_tool_calls_json {
1181 body.push_str(
1182 " toolCallsJson ??= JsonSerializer.Serialize(tcs.Select(tc => new { function = new { name = tc.Function?.Name } }));\n",
1183 );
1184 }
1185 if needs_tool_calls_0_function_name {
1186 body.push_str(" toolCalls0FunctionName ??= tcs[0].Function?.Name;\n");
1187 }
1188 body.push_str(" }\n");
1189 }
1190 body.push_str(" }\n");
1191 if needs_total_tokens {
1192 body.push_str(" if (chunk.Usage != null)\n");
1193 body.push_str(" {\n");
1194 body.push_str(" totalTokens = chunk.Usage.TotalTokens;\n");
1195 body.push_str(" }\n");
1196 }
1197 body.push_str(" }\n");
1198 body.push_str(" streamComplete = true;\n");
1199
1200 let mut had_explicit_complete = false;
1202 for assertion in &fixture.assertions {
1203 if assertion.field.as_deref() == Some("stream_complete") {
1204 had_explicit_complete = true;
1205 }
1206 emit_chat_stream_assertion(&mut body, assertion);
1207 }
1208 if !had_explicit_complete {
1209 body.push_str(" Assert.True(streamComplete);\n");
1210 }
1211
1212 body.push_str(" }\n");
1213
1214 for line in body.lines() {
1215 out.push_str(" ");
1216 out.push_str(line);
1217 out.push('\n');
1218 }
1219}
1220
1221fn emit_chat_stream_assertion(out: &mut String, assertion: &Assertion) {
1225 let atype = assertion.assertion_type.as_str();
1226 if atype == "not_error" || atype == "error" {
1227 return;
1228 }
1229 let field = assertion.field.as_deref().unwrap_or("");
1230
1231 enum Kind {
1232 Chunks,
1233 Bool,
1234 Str,
1235 IntTokens,
1236 Json,
1237 Unsupported,
1238 }
1239
1240 let (expr, kind) = match field {
1241 "chunks" => ("chunks", Kind::Chunks),
1242 "stream_content" => ("streamContent.ToString()", Kind::Str),
1243 "stream_complete" => ("streamComplete", Kind::Bool),
1244 "no_chunks_after_done" => ("streamComplete", Kind::Bool),
1245 "finish_reason" => ("lastFinishReason", Kind::Str),
1246 "tool_calls" => ("toolCallsJson", Kind::Json),
1247 "tool_calls[0].function.name" => ("toolCalls0FunctionName", Kind::Str),
1248 "usage.total_tokens" => ("totalTokens", Kind::IntTokens),
1249 _ => ("", Kind::Unsupported),
1250 };
1251
1252 if matches!(kind, Kind::Unsupported) {
1253 let _ = writeln!(
1254 out,
1255 " // skipped: streaming assertion on unsupported field '{field}'"
1256 );
1257 return;
1258 }
1259
1260 match (atype, &kind) {
1261 ("count_min", Kind::Chunks) => {
1262 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1263 let _ = writeln!(
1264 out,
1265 " Assert.True(chunks.Count >= {n}, \"expected at least {n} chunks\");"
1266 );
1267 }
1268 }
1269 ("count_equals", Kind::Chunks) => {
1270 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1271 let _ = writeln!(out, " Assert.Equal({n}, chunks.Count);");
1272 }
1273 }
1274 ("equals", Kind::Str) => {
1275 if let Some(val) = &assertion.value {
1276 let cs_val = json_to_csharp(val);
1277 let _ = writeln!(out, " Assert.Equal({cs_val}, {expr});");
1278 }
1279 }
1280 ("contains", Kind::Str) => {
1281 if let Some(val) = &assertion.value {
1282 let cs_val = json_to_csharp(val);
1283 let _ = writeln!(out, " Assert.Contains({cs_val}, {expr} ?? string.Empty);");
1284 }
1285 }
1286 ("not_empty", Kind::Str) => {
1287 let _ = writeln!(out, " Assert.False(string.IsNullOrEmpty({expr}));");
1288 }
1289 ("not_empty", Kind::Json) => {
1290 let _ = writeln!(out, " Assert.NotNull({expr});");
1291 }
1292 ("is_empty", Kind::Str) => {
1293 let _ = writeln!(out, " Assert.True(string.IsNullOrEmpty({expr}));");
1294 }
1295 ("is_true", Kind::Bool) => {
1296 let _ = writeln!(out, " Assert.True({expr});");
1297 }
1298 ("is_false", Kind::Bool) => {
1299 let _ = writeln!(out, " Assert.False({expr});");
1300 }
1301 ("greater_than_or_equal", Kind::IntTokens) => {
1302 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1303 let _ = writeln!(out, " Assert.True({expr} >= {n}, \"expected >= {n}\");");
1304 }
1305 }
1306 ("equals", Kind::IntTokens) => {
1307 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1308 let _ = writeln!(out, " Assert.Equal((long?){n}, {expr});");
1309 }
1310 }
1311 _ => {
1312 let _ = writeln!(
1313 out,
1314 " // skipped: streaming assertion '{atype}' on field '{field}' not supported"
1315 );
1316 }
1317 }
1318}
1319
1320#[allow(clippy::too_many_arguments)]
1324fn build_args_and_setup(
1325 input: &serde_json::Value,
1326 args: &[crate::config::ArgMapping],
1327 class_name: &str,
1328 options_type: Option<&str>,
1329 options_via: Option<&str>,
1330 enum_fields: &HashMap<String, String>,
1331 nested_types: &HashMap<String, String>,
1332 fixture_id: &str,
1333) -> (Vec<String>, String) {
1334 if args.is_empty() {
1335 return (Vec::new(), String::new());
1336 }
1337
1338 let mut setup_lines: Vec<String> = Vec::new();
1339 let mut parts: Vec<String> = Vec::new();
1340
1341 for arg in args {
1342 if arg.arg_type == "bytes" {
1343 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1345 let val = input.get(field);
1346 match val {
1347 None | Some(serde_json::Value::Null) if arg.optional => {
1348 parts.push("null".to_string());
1349 }
1350 None | Some(serde_json::Value::Null) => {
1351 parts.push("System.Array.Empty<byte>()".to_string());
1352 }
1353 Some(v) => {
1354 if let Some(s) = v.as_str() {
1359 let bytes_code = classify_bytes_value_csharp(s);
1360 parts.push(bytes_code);
1361 } else {
1362 let cs_str = json_to_csharp(v);
1364 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
1365 }
1366 }
1367 }
1368 continue;
1369 }
1370
1371 if arg.arg_type == "mock_url" {
1372 setup_lines.push(format!(
1373 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
1374 arg.name,
1375 ));
1376 parts.push(arg.name.clone());
1377 continue;
1378 }
1379
1380 if arg.arg_type == "handle" {
1381 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
1383 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1384 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1385 if config_value.is_null()
1386 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1387 {
1388 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
1389 } else {
1390 let sorted = sort_discriminator_first(config_value.clone());
1394 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1395 let name = &arg.name;
1396 setup_lines.push(format!(
1397 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
1398 escape_csharp(&json_str),
1399 ));
1400 setup_lines.push(format!(
1401 "var {} = {class_name}.{constructor_name}({name}Config);",
1402 arg.name,
1403 name = name,
1404 ));
1405 }
1406 parts.push(arg.name.clone());
1407 continue;
1408 }
1409
1410 let val: Option<&serde_json::Value> = if arg.field == "input" {
1413 Some(input)
1414 } else {
1415 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1416 input.get(field)
1417 };
1418 match val {
1419 None | Some(serde_json::Value::Null) if arg.optional => {
1420 parts.push("null".to_string());
1423 continue;
1424 }
1425 None | Some(serde_json::Value::Null) => {
1426 let default_val = match arg.arg_type.as_str() {
1430 "string" => "\"\"".to_string(),
1431 "int" | "integer" => "0".to_string(),
1432 "float" | "number" => "0.0d".to_string(),
1433 "bool" | "boolean" => "false".to_string(),
1434 "json_object" => {
1435 if let Some(opts_type) = options_type {
1436 format!("new {opts_type}()")
1437 } else {
1438 "null".to_string()
1439 }
1440 }
1441 _ => "null".to_string(),
1442 };
1443 parts.push(default_val);
1444 }
1445 Some(v) => {
1446 if arg.arg_type == "json_object" {
1447 if options_via == Some("from_json")
1453 && let Some(opts_type) = options_type
1454 {
1455 let sorted = sort_discriminator_first(v.clone());
1456 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
1457 let escaped = escape_csharp(&json_str);
1458 parts.push(format!("{opts_type}.FromJson(\"{escaped}\")",));
1464 continue;
1465 }
1466 if let Some(arr) = v.as_array() {
1468 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
1469 continue;
1470 }
1471 if let Some(opts_type) = options_type {
1473 if let Some(obj) = v.as_object() {
1474 parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
1475 continue;
1476 }
1477 }
1478 }
1479 parts.push(json_to_csharp(v));
1480 }
1481 }
1482 }
1483
1484 (setup_lines, parts.join(", "))
1485}
1486
1487fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
1495 match element_type {
1496 Some("BatchBytesItem") => {
1497 let items: Vec<String> = arr
1498 .iter()
1499 .filter_map(|v| v.as_object())
1500 .map(|obj| {
1501 let content = obj.get("content").and_then(|v| v.as_array());
1502 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1503 let content_code = if let Some(arr) = content {
1504 let bytes: Vec<String> = arr
1505 .iter()
1506 .filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
1507 .collect();
1508 format!("new byte[] {{ {} }}", bytes.join(", "))
1509 } else {
1510 "new byte[] { }".to_string()
1511 };
1512 format!(
1513 "new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
1514 content_code, mime_type
1515 )
1516 })
1517 .collect();
1518 format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
1519 }
1520 Some("BatchFileItem") => {
1521 let items: Vec<String> = arr
1522 .iter()
1523 .filter_map(|v| v.as_object())
1524 .map(|obj| {
1525 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1526 format!("new BatchFileItem {{ Path = \"{}\" }}", path)
1527 })
1528 .collect();
1529 format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
1530 }
1531 Some("f32") => {
1532 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
1533 format!("new List<float>() {{ {} }}", items.join(", "))
1534 }
1535 Some("(String, String)") => {
1536 let items: Vec<String> = arr
1537 .iter()
1538 .map(|v| {
1539 let strs: Vec<String> = v
1540 .as_array()
1541 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
1542 format!("new List<string>() {{ {} }}", strs.join(", "))
1543 })
1544 .collect();
1545 format!("new List<List<string>>() {{ {} }}", items.join(", "))
1546 }
1547 Some(et)
1548 if et != "f32"
1549 && et != "(String, String)"
1550 && et != "string"
1551 && et != "BatchBytesItem"
1552 && et != "BatchFileItem" =>
1553 {
1554 let items: Vec<String> = arr
1556 .iter()
1557 .map(|v| {
1558 let json_str = serde_json::to_string(v).unwrap_or_default();
1559 let escaped = escape_csharp(&json_str);
1560 format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
1561 })
1562 .collect();
1563 format!("new List<{et}>() {{ {} }}", items.join(", "))
1564 }
1565 _ => {
1566 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1567 format!("new List<string>() {{ {} }}", items.join(", "))
1568 }
1569 }
1570}
1571
1572fn parse_discriminated_union_access(field: &str) -> Option<(String, String, String)> {
1576 let parts: Vec<&str> = field.split('.').collect();
1577 if parts.len() >= 3 && parts.len() <= 4 {
1578 if parts[0] == "metadata" && parts[1] == "format" {
1580 let variant_name = parts[2];
1581 let known_variants = [
1583 "pdf",
1584 "docx",
1585 "excel",
1586 "email",
1587 "pptx",
1588 "archive",
1589 "image",
1590 "xml",
1591 "text",
1592 "html",
1593 "ocr",
1594 "csv",
1595 "bibtex",
1596 "citation",
1597 "fiction_book",
1598 "dbf",
1599 "jats",
1600 "epub",
1601 "pst",
1602 "code",
1603 ];
1604 if known_variants.contains(&variant_name) {
1605 let variant_pascal = variant_name.to_upper_camel_case();
1606 if parts.len() == 4 {
1607 let inner_field = parts[3];
1608 return Some((
1609 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1610 variant_pascal,
1611 inner_field.to_string(),
1612 ));
1613 } else if parts.len() == 3 {
1614 return Some((
1616 format!("result.Metadata.Format! as FormatMetadata.{}", variant_pascal),
1617 variant_pascal,
1618 String::new(),
1619 ));
1620 }
1621 }
1622 }
1623 }
1624 None
1625}
1626
1627fn render_discriminated_union_assertion(
1631 out: &mut String,
1632 assertion: &Assertion,
1633 variant_var: &str,
1634 inner_field: &str,
1635 _result_is_vec: bool,
1636) {
1637 if inner_field.is_empty() {
1638 return; }
1640
1641 let field_pascal = inner_field.to_upper_camel_case();
1642 let field_expr = format!("{variant_var}.Value.{field_pascal}");
1643
1644 match assertion.assertion_type.as_str() {
1645 "equals" => {
1646 if let Some(expected) = &assertion.value {
1647 let cs_val = json_to_csharp(expected);
1648 if expected.is_string() {
1649 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}!.Trim());");
1650 } else if expected.as_bool() == Some(true) {
1651 let _ = writeln!(out, " Assert.True({field_expr});");
1652 } else if expected.as_bool() == Some(false) {
1653 let _ = writeln!(out, " Assert.False({field_expr});");
1654 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
1655 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
1656 } else {
1657 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
1658 }
1659 }
1660 }
1661 "greater_than_or_equal" => {
1662 if let Some(val) = &assertion.value {
1663 let cs_val = json_to_csharp(val);
1664 let _ = writeln!(
1665 out,
1666 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1667 );
1668 }
1669 }
1670 "contains_all" => {
1671 if let Some(values) = &assertion.values {
1672 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1673 for val in values {
1674 let lower_val = val.as_str().map(|s| s.to_lowercase());
1675 let cs_val = lower_val
1676 .as_deref()
1677 .map(|s| format!("\"{}\"", escape_csharp(s)))
1678 .unwrap_or_else(|| json_to_csharp(val));
1679 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1680 }
1681 }
1682 }
1683 "contains" => {
1684 if let Some(expected) = &assertion.value {
1685 let field_as_str = format!("JsonSerializer.Serialize({field_expr})");
1686 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
1687 let cs_val = lower_expected
1688 .as_deref()
1689 .map(|s| format!("\"{}\"", escape_csharp(s)))
1690 .unwrap_or_else(|| json_to_csharp(expected));
1691 let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
1692 }
1693 }
1694 "not_empty" => {
1695 let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
1696 }
1697 "is_empty" => {
1698 let _ = writeln!(out, " Assert.Empty({field_expr});");
1699 }
1700 _ => {
1701 let _ = writeln!(
1702 out,
1703 " // skipped: assertion type '{}' not yet supported for discriminated union fields",
1704 assertion.assertion_type
1705 );
1706 }
1707 }
1708}
1709
1710#[allow(clippy::too_many_arguments)]
1711fn render_assertion(
1712 out: &mut String,
1713 assertion: &Assertion,
1714 result_var: &str,
1715 class_name: &str,
1716 exception_class: &str,
1717 field_resolver: &FieldResolver,
1718 result_is_simple: bool,
1719 result_is_vec: bool,
1720 result_is_array: bool,
1721 result_is_bytes: bool,
1722 fields_enum: &std::collections::HashSet<String>,
1723) {
1724 if result_is_bytes {
1728 match assertion.assertion_type.as_str() {
1729 "not_empty" => {
1730 let _ = writeln!(out, " Assert.NotEmpty({result_var});");
1731 return;
1732 }
1733 "is_empty" => {
1734 let _ = writeln!(out, " Assert.Empty({result_var});");
1735 return;
1736 }
1737 "count_equals" | "length_equals" => {
1738 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1739 let _ = writeln!(out, " Assert.Equal({n}, {result_var}.Length);");
1740 }
1741 return;
1742 }
1743 "count_min" | "length_min" => {
1744 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1745 let _ = writeln!(out, " Assert.True({result_var}.Length >= {n});");
1746 }
1747 return;
1748 }
1749 _ => {
1750 let _ = writeln!(
1754 out,
1755 " // skipped: assertion type '{}' not supported on byte[] result",
1756 assertion.assertion_type
1757 );
1758 return;
1759 }
1760 }
1761 }
1762 if let Some(f) = &assertion.field {
1765 match f.as_str() {
1766 "chunks_have_content" => {
1767 let synthetic_pred =
1768 format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
1769 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1770 "is_true" => "is_true",
1771 "is_false" => "is_false",
1772 _ => {
1773 out.push_str(&format!(
1774 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1775 ));
1776 return;
1777 }
1778 };
1779 let rendered = crate::template_env::render(
1780 "csharp/assertion.jinja",
1781 minijinja::context! {
1782 assertion_type => "synthetic_assertion",
1783 synthetic_pred => synthetic_pred,
1784 synthetic_pred_type => synthetic_pred_type,
1785 },
1786 );
1787 out.push_str(&rendered);
1788 return;
1789 }
1790 "chunks_have_embeddings" => {
1791 let synthetic_pred =
1792 format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
1793 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1794 "is_true" => "is_true",
1795 "is_false" => "is_false",
1796 _ => {
1797 out.push_str(&format!(
1798 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1799 ));
1800 return;
1801 }
1802 };
1803 let rendered = crate::template_env::render(
1804 "csharp/assertion.jinja",
1805 minijinja::context! {
1806 assertion_type => "synthetic_assertion",
1807 synthetic_pred => synthetic_pred,
1808 synthetic_pred_type => synthetic_pred_type,
1809 },
1810 );
1811 out.push_str(&rendered);
1812 return;
1813 }
1814 "embeddings" => {
1818 match assertion.assertion_type.as_str() {
1819 "count_equals" => {
1820 if let Some(val) = &assertion.value {
1821 if let Some(n) = val.as_u64() {
1822 let rendered = crate::template_env::render(
1823 "csharp/assertion.jinja",
1824 minijinja::context! {
1825 assertion_type => "synthetic_embeddings_count_equals",
1826 synthetic_pred => format!("{result_var}.Count"),
1827 n => n,
1828 },
1829 );
1830 out.push_str(&rendered);
1831 }
1832 }
1833 }
1834 "count_min" => {
1835 if let Some(val) = &assertion.value {
1836 if let Some(n) = val.as_u64() {
1837 let rendered = crate::template_env::render(
1838 "csharp/assertion.jinja",
1839 minijinja::context! {
1840 assertion_type => "synthetic_embeddings_count_min",
1841 synthetic_pred => format!("{result_var}.Count"),
1842 n => n,
1843 },
1844 );
1845 out.push_str(&rendered);
1846 }
1847 }
1848 }
1849 "not_empty" => {
1850 let rendered = crate::template_env::render(
1851 "csharp/assertion.jinja",
1852 minijinja::context! {
1853 assertion_type => "synthetic_embeddings_not_empty",
1854 synthetic_pred => result_var.to_string(),
1855 },
1856 );
1857 out.push_str(&rendered);
1858 }
1859 "is_empty" => {
1860 let rendered = crate::template_env::render(
1861 "csharp/assertion.jinja",
1862 minijinja::context! {
1863 assertion_type => "synthetic_embeddings_is_empty",
1864 synthetic_pred => result_var.to_string(),
1865 },
1866 );
1867 out.push_str(&rendered);
1868 }
1869 _ => {
1870 out.push_str(
1871 " // skipped: unsupported assertion type on synthetic field 'embeddings'\n",
1872 );
1873 }
1874 }
1875 return;
1876 }
1877 "embedding_dimensions" => {
1878 let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
1879 match assertion.assertion_type.as_str() {
1880 "equals" => {
1881 if let Some(val) = &assertion.value {
1882 if let Some(n) = val.as_u64() {
1883 let rendered = crate::template_env::render(
1884 "csharp/assertion.jinja",
1885 minijinja::context! {
1886 assertion_type => "synthetic_embedding_dimensions_equals",
1887 synthetic_pred => expr,
1888 n => n,
1889 },
1890 );
1891 out.push_str(&rendered);
1892 }
1893 }
1894 }
1895 "greater_than" => {
1896 if let Some(val) = &assertion.value {
1897 if let Some(n) = val.as_u64() {
1898 let rendered = crate::template_env::render(
1899 "csharp/assertion.jinja",
1900 minijinja::context! {
1901 assertion_type => "synthetic_embedding_dimensions_greater_than",
1902 synthetic_pred => expr,
1903 n => n,
1904 },
1905 );
1906 out.push_str(&rendered);
1907 }
1908 }
1909 }
1910 _ => {
1911 out.push_str(" // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n");
1912 }
1913 }
1914 return;
1915 }
1916 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1917 let synthetic_pred = match f.as_str() {
1918 "embeddings_valid" => {
1919 format!("{result_var}.All(e => e.Count > 0)")
1920 }
1921 "embeddings_finite" => {
1922 format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
1923 }
1924 "embeddings_non_zero" => {
1925 format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
1926 }
1927 "embeddings_normalized" => {
1928 format!(
1929 "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
1930 )
1931 }
1932 _ => unreachable!(),
1933 };
1934 let synthetic_pred_type = match assertion.assertion_type.as_str() {
1935 "is_true" => "is_true",
1936 "is_false" => "is_false",
1937 _ => {
1938 out.push_str(&format!(
1939 " // skipped: unsupported assertion type on synthetic field '{f}'\n"
1940 ));
1941 return;
1942 }
1943 };
1944 let rendered = crate::template_env::render(
1945 "csharp/assertion.jinja",
1946 minijinja::context! {
1947 assertion_type => "synthetic_assertion",
1948 synthetic_pred => synthetic_pred,
1949 synthetic_pred_type => synthetic_pred_type,
1950 },
1951 );
1952 out.push_str(&rendered);
1953 return;
1954 }
1955 "keywords" | "keywords_count" => {
1958 let skipped_reason = format!("field '{f}' not available on C# ExtractionResult");
1959 let rendered = crate::template_env::render(
1960 "csharp/assertion.jinja",
1961 minijinja::context! {
1962 skipped_reason => skipped_reason,
1963 },
1964 );
1965 out.push_str(&rendered);
1966 return;
1967 }
1968 _ => {}
1969 }
1970 }
1971
1972 if let Some(f) = &assertion.field {
1974 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1975 let skipped_reason = format!("field '{f}' not available on result type");
1976 let rendered = crate::template_env::render(
1977 "csharp/assertion.jinja",
1978 minijinja::context! {
1979 skipped_reason => skipped_reason,
1980 },
1981 );
1982 out.push_str(&rendered);
1983 return;
1984 }
1985 }
1986
1987 let is_count_assertion = matches!(
1990 assertion.assertion_type.as_str(),
1991 "count_equals" | "count_min" | "count_max"
1992 );
1993 let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
1994 let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
1995
1996 let effective_result_var: String = if result_is_vec && !use_list_directly {
1997 format!("{result_var}[0]")
1998 } else {
1999 result_var.to_string()
2000 };
2001
2002 let is_discriminated_union = assertion
2004 .field
2005 .as_ref()
2006 .is_some_and(|f| parse_discriminated_union_access(f).is_some());
2007
2008 if is_discriminated_union {
2010 if let Some((_, variant_name, inner_field)) = assertion
2011 .field
2012 .as_ref()
2013 .and_then(|f| parse_discriminated_union_access(f))
2014 {
2015 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2017 inner_field.hash(&mut hasher);
2018 let var_hash = format!("{:x}", hasher.finish());
2019 let variant_var = format!("variant_{}", &var_hash[..8]);
2020 let _ = writeln!(
2021 out,
2022 " if ({effective_result_var}.Metadata.Format is FormatMetadata.{} {})",
2023 variant_name, &variant_var
2024 );
2025 let _ = writeln!(out, " {{");
2026 render_discriminated_union_assertion(out, assertion, &variant_var, &inner_field, result_is_vec);
2027 let _ = writeln!(out, " }}");
2028 let _ = writeln!(out, " else");
2029 let _ = writeln!(out, " {{");
2030 let _ = writeln!(
2031 out,
2032 " Assert.Fail(\"Expected {} format metadata\");",
2033 variant_name.to_lowercase()
2034 );
2035 let _ = writeln!(out, " }}");
2036 return;
2037 }
2038 }
2039
2040 let field_expr = if result_is_simple {
2041 effective_result_var.clone()
2042 } else {
2043 match &assertion.field {
2044 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
2045 _ => effective_result_var.clone(),
2046 }
2047 };
2048
2049 let field_needs_json_serialize = if result_is_simple {
2053 result_is_array
2056 } else {
2057 match &assertion.field {
2058 Some(f) if !f.is_empty() => field_resolver.is_array(f),
2059 _ => !result_is_simple,
2061 }
2062 };
2063 let field_as_str = if field_needs_json_serialize {
2065 format!("JsonSerializer.Serialize({field_expr})")
2066 } else {
2067 format!("{field_expr}.ToString()")
2068 };
2069
2070 let field_is_enum = assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
2074 let resolved = field_resolver.resolve(f);
2075 fields_enum.contains(f) || fields_enum.contains(resolved)
2076 });
2077
2078 match assertion.assertion_type.as_str() {
2079 "equals" => {
2080 if let Some(expected) = &assertion.value {
2081 if field_is_enum && expected.is_string() {
2088 let s_lower = expected.as_str().map(|s| s.to_lowercase()).unwrap_or_default();
2089 let _ = writeln!(
2090 out,
2091 " Assert.Equal(\"{}\", {field_expr} == null ? null : JsonNamingPolicy.SnakeCaseLower.ConvertName({field_expr}.ToString()!));",
2092 escape_csharp(&s_lower)
2093 );
2094 return;
2095 }
2096 let cs_val = json_to_csharp(expected);
2097 let is_string_val = expected.is_string();
2098 let is_bool_true = expected.as_bool() == Some(true);
2099 let is_bool_false = expected.as_bool() == Some(false);
2100 let is_integer_val = expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0);
2101
2102 let rendered = crate::template_env::render(
2103 "csharp/assertion.jinja",
2104 minijinja::context! {
2105 assertion_type => "equals",
2106 field_expr => field_expr.clone(),
2107 cs_val => cs_val,
2108 is_string_val => is_string_val,
2109 is_bool_true => is_bool_true,
2110 is_bool_false => is_bool_false,
2111 is_integer_val => is_integer_val,
2112 },
2113 );
2114 out.push_str(&rendered);
2115 }
2116 }
2117 "contains" => {
2118 if let Some(expected) = &assertion.value {
2119 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
2126 let cs_val = lower_expected
2127 .as_deref()
2128 .map(|s| format!("\"{}\"", escape_csharp(s)))
2129 .unwrap_or_else(|| json_to_csharp(expected));
2130
2131 let rendered = crate::template_env::render(
2132 "csharp/assertion.jinja",
2133 minijinja::context! {
2134 assertion_type => "contains",
2135 field_as_str => field_as_str.clone(),
2136 cs_val => cs_val,
2137 },
2138 );
2139 out.push_str(&rendered);
2140 }
2141 }
2142 "contains_all" => {
2143 if let Some(values) = &assertion.values {
2144 let values_cs_lower: Vec<String> = values
2145 .iter()
2146 .map(|val| {
2147 let lower_val = val.as_str().map(|s| s.to_lowercase());
2148 lower_val
2149 .as_deref()
2150 .map(|s| format!("\"{}\"", escape_csharp(s)))
2151 .unwrap_or_else(|| json_to_csharp(val))
2152 })
2153 .collect();
2154
2155 let rendered = crate::template_env::render(
2156 "csharp/assertion.jinja",
2157 minijinja::context! {
2158 assertion_type => "contains_all",
2159 field_as_str => field_as_str.clone(),
2160 values_cs_lower => values_cs_lower,
2161 },
2162 );
2163 out.push_str(&rendered);
2164 }
2165 }
2166 "not_contains" => {
2167 if let Some(expected) = &assertion.value {
2168 let cs_val = json_to_csharp(expected);
2169
2170 let rendered = crate::template_env::render(
2171 "csharp/assertion.jinja",
2172 minijinja::context! {
2173 assertion_type => "not_contains",
2174 field_as_str => field_as_str.clone(),
2175 cs_val => cs_val,
2176 },
2177 );
2178 out.push_str(&rendered);
2179 }
2180 }
2181 "not_empty" => {
2182 let rendered = crate::template_env::render(
2183 "csharp/assertion.jinja",
2184 minijinja::context! {
2185 assertion_type => "not_empty",
2186 field_expr => field_expr.clone(),
2187 field_needs_json_serialize => field_needs_json_serialize,
2188 },
2189 );
2190 out.push_str(&rendered);
2191 }
2192 "is_empty" => {
2193 let rendered = crate::template_env::render(
2194 "csharp/assertion.jinja",
2195 minijinja::context! {
2196 assertion_type => "is_empty",
2197 field_expr => field_expr.clone(),
2198 field_needs_json_serialize => field_needs_json_serialize,
2199 },
2200 );
2201 out.push_str(&rendered);
2202 }
2203 "contains_any" => {
2204 if let Some(values) = &assertion.values {
2205 let checks: Vec<String> = values
2206 .iter()
2207 .map(|v| {
2208 let cs_val = json_to_csharp(v);
2209 format!("{field_as_str}.Contains({cs_val})")
2210 })
2211 .collect();
2212 let contains_any_expr = checks.join(" || ");
2213
2214 let rendered = crate::template_env::render(
2215 "csharp/assertion.jinja",
2216 minijinja::context! {
2217 assertion_type => "contains_any",
2218 contains_any_expr => contains_any_expr,
2219 },
2220 );
2221 out.push_str(&rendered);
2222 }
2223 }
2224 "greater_than" => {
2225 if let Some(val) = &assertion.value {
2226 let cs_val = json_to_csharp(val);
2227
2228 let rendered = crate::template_env::render(
2229 "csharp/assertion.jinja",
2230 minijinja::context! {
2231 assertion_type => "greater_than",
2232 field_expr => field_expr.clone(),
2233 cs_val => cs_val,
2234 },
2235 );
2236 out.push_str(&rendered);
2237 }
2238 }
2239 "less_than" => {
2240 if let Some(val) = &assertion.value {
2241 let cs_val = json_to_csharp(val);
2242
2243 let rendered = crate::template_env::render(
2244 "csharp/assertion.jinja",
2245 minijinja::context! {
2246 assertion_type => "less_than",
2247 field_expr => field_expr.clone(),
2248 cs_val => cs_val,
2249 },
2250 );
2251 out.push_str(&rendered);
2252 }
2253 }
2254 "greater_than_or_equal" => {
2255 if let Some(val) = &assertion.value {
2256 let cs_val = json_to_csharp(val);
2257
2258 let rendered = crate::template_env::render(
2259 "csharp/assertion.jinja",
2260 minijinja::context! {
2261 assertion_type => "greater_than_or_equal",
2262 field_expr => field_expr.clone(),
2263 cs_val => cs_val,
2264 },
2265 );
2266 out.push_str(&rendered);
2267 }
2268 }
2269 "less_than_or_equal" => {
2270 if let Some(val) = &assertion.value {
2271 let cs_val = json_to_csharp(val);
2272
2273 let rendered = crate::template_env::render(
2274 "csharp/assertion.jinja",
2275 minijinja::context! {
2276 assertion_type => "less_than_or_equal",
2277 field_expr => field_expr.clone(),
2278 cs_val => cs_val,
2279 },
2280 );
2281 out.push_str(&rendered);
2282 }
2283 }
2284 "starts_with" => {
2285 if let Some(expected) = &assertion.value {
2286 let cs_val = json_to_csharp(expected);
2287
2288 let rendered = crate::template_env::render(
2289 "csharp/assertion.jinja",
2290 minijinja::context! {
2291 assertion_type => "starts_with",
2292 field_expr => field_expr.clone(),
2293 cs_val => cs_val,
2294 },
2295 );
2296 out.push_str(&rendered);
2297 }
2298 }
2299 "ends_with" => {
2300 if let Some(expected) = &assertion.value {
2301 let cs_val = json_to_csharp(expected);
2302
2303 let rendered = crate::template_env::render(
2304 "csharp/assertion.jinja",
2305 minijinja::context! {
2306 assertion_type => "ends_with",
2307 field_expr => field_expr.clone(),
2308 cs_val => cs_val,
2309 },
2310 );
2311 out.push_str(&rendered);
2312 }
2313 }
2314 "min_length" => {
2315 if let Some(val) = &assertion.value {
2316 if let Some(n) = val.as_u64() {
2317 let rendered = crate::template_env::render(
2318 "csharp/assertion.jinja",
2319 minijinja::context! {
2320 assertion_type => "min_length",
2321 field_expr => field_expr.clone(),
2322 n => n,
2323 },
2324 );
2325 out.push_str(&rendered);
2326 }
2327 }
2328 }
2329 "max_length" => {
2330 if let Some(val) = &assertion.value {
2331 if let Some(n) = val.as_u64() {
2332 let rendered = crate::template_env::render(
2333 "csharp/assertion.jinja",
2334 minijinja::context! {
2335 assertion_type => "max_length",
2336 field_expr => field_expr.clone(),
2337 n => n,
2338 },
2339 );
2340 out.push_str(&rendered);
2341 }
2342 }
2343 }
2344 "count_min" => {
2345 if let Some(val) = &assertion.value {
2346 if let Some(n) = val.as_u64() {
2347 let rendered = crate::template_env::render(
2348 "csharp/assertion.jinja",
2349 minijinja::context! {
2350 assertion_type => "count_min",
2351 field_expr => field_expr.clone(),
2352 n => n,
2353 },
2354 );
2355 out.push_str(&rendered);
2356 }
2357 }
2358 }
2359 "count_equals" => {
2360 if let Some(val) = &assertion.value {
2361 if let Some(n) = val.as_u64() {
2362 let rendered = crate::template_env::render(
2363 "csharp/assertion.jinja",
2364 minijinja::context! {
2365 assertion_type => "count_equals",
2366 field_expr => field_expr.clone(),
2367 n => n,
2368 },
2369 );
2370 out.push_str(&rendered);
2371 }
2372 }
2373 }
2374 "is_true" => {
2375 let rendered = crate::template_env::render(
2376 "csharp/assertion.jinja",
2377 minijinja::context! {
2378 assertion_type => "is_true",
2379 field_expr => field_expr.clone(),
2380 },
2381 );
2382 out.push_str(&rendered);
2383 }
2384 "is_false" => {
2385 let rendered = crate::template_env::render(
2386 "csharp/assertion.jinja",
2387 minijinja::context! {
2388 assertion_type => "is_false",
2389 field_expr => field_expr.clone(),
2390 },
2391 );
2392 out.push_str(&rendered);
2393 }
2394 "not_error" => {
2395 let rendered = crate::template_env::render(
2397 "csharp/assertion.jinja",
2398 minijinja::context! {
2399 assertion_type => "not_error",
2400 },
2401 );
2402 out.push_str(&rendered);
2403 }
2404 "error" => {
2405 let rendered = crate::template_env::render(
2407 "csharp/assertion.jinja",
2408 minijinja::context! {
2409 assertion_type => "error",
2410 },
2411 );
2412 out.push_str(&rendered);
2413 }
2414 "method_result" => {
2415 if let Some(method_name) = &assertion.method {
2416 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
2417 let check = assertion.check.as_deref().unwrap_or("is_true");
2418
2419 match check {
2420 "equals" => {
2421 if let Some(val) = &assertion.value {
2422 let is_check_bool_true = val.as_bool() == Some(true);
2423 let is_check_bool_false = val.as_bool() == Some(false);
2424 let cs_check_val = json_to_csharp(val);
2425
2426 let rendered = crate::template_env::render(
2427 "csharp/assertion.jinja",
2428 minijinja::context! {
2429 assertion_type => "method_result",
2430 check => "equals",
2431 call_expr => call_expr.clone(),
2432 is_check_bool_true => is_check_bool_true,
2433 is_check_bool_false => is_check_bool_false,
2434 cs_check_val => cs_check_val,
2435 },
2436 );
2437 out.push_str(&rendered);
2438 }
2439 }
2440 "is_true" => {
2441 let rendered = crate::template_env::render(
2442 "csharp/assertion.jinja",
2443 minijinja::context! {
2444 assertion_type => "method_result",
2445 check => "is_true",
2446 call_expr => call_expr.clone(),
2447 },
2448 );
2449 out.push_str(&rendered);
2450 }
2451 "is_false" => {
2452 let rendered = crate::template_env::render(
2453 "csharp/assertion.jinja",
2454 minijinja::context! {
2455 assertion_type => "method_result",
2456 check => "is_false",
2457 call_expr => call_expr.clone(),
2458 },
2459 );
2460 out.push_str(&rendered);
2461 }
2462 "greater_than_or_equal" => {
2463 if let Some(val) = &assertion.value {
2464 let check_n = val.as_u64().unwrap_or(0);
2465
2466 let rendered = crate::template_env::render(
2467 "csharp/assertion.jinja",
2468 minijinja::context! {
2469 assertion_type => "method_result",
2470 check => "greater_than_or_equal",
2471 call_expr => call_expr.clone(),
2472 check_n => check_n,
2473 },
2474 );
2475 out.push_str(&rendered);
2476 }
2477 }
2478 "count_min" => {
2479 if let Some(val) = &assertion.value {
2480 let check_n = val.as_u64().unwrap_or(0);
2481
2482 let rendered = crate::template_env::render(
2483 "csharp/assertion.jinja",
2484 minijinja::context! {
2485 assertion_type => "method_result",
2486 check => "count_min",
2487 call_expr => call_expr.clone(),
2488 check_n => check_n,
2489 },
2490 );
2491 out.push_str(&rendered);
2492 }
2493 }
2494 "is_error" => {
2495 let rendered = crate::template_env::render(
2496 "csharp/assertion.jinja",
2497 minijinja::context! {
2498 assertion_type => "method_result",
2499 check => "is_error",
2500 call_expr => call_expr.clone(),
2501 exception_class => exception_class,
2502 },
2503 );
2504 out.push_str(&rendered);
2505 }
2506 "contains" => {
2507 if let Some(val) = &assertion.value {
2508 let cs_check_val = json_to_csharp(val);
2509
2510 let rendered = crate::template_env::render(
2511 "csharp/assertion.jinja",
2512 minijinja::context! {
2513 assertion_type => "method_result",
2514 check => "contains",
2515 call_expr => call_expr.clone(),
2516 cs_check_val => cs_check_val,
2517 },
2518 );
2519 out.push_str(&rendered);
2520 }
2521 }
2522 other_check => {
2523 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
2524 }
2525 }
2526 } else {
2527 panic!("C# e2e generator: method_result assertion missing 'method' field");
2528 }
2529 }
2530 "matches_regex" => {
2531 if let Some(expected) = &assertion.value {
2532 let cs_val = json_to_csharp(expected);
2533
2534 let rendered = crate::template_env::render(
2535 "csharp/assertion.jinja",
2536 minijinja::context! {
2537 assertion_type => "matches_regex",
2538 field_expr => field_expr.clone(),
2539 cs_val => cs_val,
2540 },
2541 );
2542 out.push_str(&rendered);
2543 }
2544 }
2545 other => {
2546 panic!("C# e2e generator: unsupported assertion type: {other}");
2547 }
2548 }
2549}
2550
2551fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
2558 match value {
2559 serde_json::Value::Object(map) => {
2560 let mut sorted = serde_json::Map::with_capacity(map.len());
2561 if let Some(type_val) = map.get("type") {
2563 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
2564 }
2565 for (k, v) in map {
2566 if k != "type" {
2567 sorted.insert(k, sort_discriminator_first(v));
2568 }
2569 }
2570 serde_json::Value::Object(sorted)
2571 }
2572 serde_json::Value::Array(arr) => {
2573 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
2574 }
2575 other => other,
2576 }
2577}
2578
2579fn json_to_csharp(value: &serde_json::Value) -> String {
2581 match value {
2582 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
2583 serde_json::Value::Bool(true) => "true".to_string(),
2584 serde_json::Value::Bool(false) => "false".to_string(),
2585 serde_json::Value::Number(n) => {
2586 if n.is_f64() {
2587 format!("{}d", n)
2588 } else {
2589 n.to_string()
2590 }
2591 }
2592 serde_json::Value::Null => "null".to_string(),
2593 serde_json::Value::Array(arr) => {
2594 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2595 format!("new[] {{ {} }}", items.join(", "))
2596 }
2597 serde_json::Value::Object(_) => {
2598 let json_str = serde_json::to_string(value).unwrap_or_default();
2599 format!("\"{}\"", escape_csharp(&json_str))
2600 }
2601 }
2602}
2603
2604fn default_csharp_nested_types() -> HashMap<String, String> {
2611 [
2612 ("chunking", "ChunkingConfig"),
2613 ("ocr", "OcrConfig"),
2614 ("images", "ImageExtractionConfig"),
2615 ("html_output", "HtmlOutputConfig"),
2616 ("language_detection", "LanguageDetectionConfig"),
2617 ("postprocessor", "PostProcessorConfig"),
2618 ("acceleration", "AccelerationConfig"),
2619 ("email", "EmailConfig"),
2620 ("pages", "PageConfig"),
2621 ("pdf_options", "PdfConfig"),
2622 ("layout", "LayoutDetectionConfig"),
2623 ("tree_sitter", "TreeSitterConfig"),
2624 ("structured_extraction", "StructuredExtractionConfig"),
2625 ("content_filter", "ContentFilterConfig"),
2626 ("token_reduction", "TokenReductionOptions"),
2627 ("security_limits", "SecurityLimits"),
2628 ("format", "FormatMetadata"),
2629 ]
2630 .iter()
2631 .map(|(k, v)| (k.to_string(), v.to_string()))
2632 .collect()
2633}
2634
2635fn csharp_object_initializer(
2643 obj: &serde_json::Map<String, serde_json::Value>,
2644 type_name: &str,
2645 enum_fields: &HashMap<String, String>,
2646 nested_types: &HashMap<String, String>,
2647) -> String {
2648 if obj.is_empty() {
2649 return format!("new {type_name}()");
2650 }
2651
2652 static IMPLICIT_ENUM_FIELDS: &[(&str, &str)] = &[("output_format", "OutputFormat")];
2655
2656 let props: Vec<String> = obj
2657 .iter()
2658 .map(|(key, val)| {
2659 let pascal_key = key.to_upper_camel_case();
2660 let implicit_enum_type = IMPLICIT_ENUM_FIELDS
2661 .iter()
2662 .find(|(k, _)| *k == key.as_str())
2663 .map(|(_, t)| *t);
2664 let cs_val =
2665 if let Some(enum_type) = enum_fields.get(key.as_str()).map(String::as_str).or(implicit_enum_type) {
2666 if val.is_null() {
2668 "null".to_string()
2669 } else {
2670 let member = val
2671 .as_str()
2672 .map(|s| s.to_upper_camel_case())
2673 .unwrap_or_else(|| "null".to_string());
2674 format!("{enum_type}.{member}")
2675 }
2676 } else if let Some(nested_type) = nested_types.get(key.as_str()) {
2677 let normalized = normalize_csharp_enum_values(val, enum_fields);
2679 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
2680 format!(
2681 "JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
2682 escape_csharp(&json_str)
2683 )
2684 } else if let Some(arr) = val.as_array() {
2685 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
2687 format!("new List<string> {{ {} }}", items.join(", "))
2688 } else {
2689 json_to_csharp(val)
2690 };
2691 format!("{pascal_key} = {cs_val}")
2692 })
2693 .collect();
2694 format!("new {} {{ {} }}", type_name, props.join(", "))
2695}
2696
2697fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
2702 match value {
2703 serde_json::Value::Object(map) => {
2704 let mut result = map.clone();
2705 for (key, val) in result.iter_mut() {
2706 if enum_fields.contains_key(key) {
2707 if let Some(s) = val.as_str() {
2709 *val = serde_json::Value::String(s.to_lowercase());
2710 }
2711 }
2712 }
2713 serde_json::Value::Object(result)
2714 }
2715 other => other.clone(),
2716 }
2717}
2718
2719fn build_csharp_visitor(
2730 setup_lines: &mut Vec<String>,
2731 class_decls: &mut Vec<String>,
2732 fixture_id: &str,
2733 visitor_spec: &crate::fixture::VisitorSpec,
2734) -> String {
2735 use heck::ToUpperCamelCase;
2736 let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
2737 let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
2738
2739 setup_lines.push(format!("var {var_name} = new {class_name}();"));
2740
2741 let mut decl = String::new();
2743 decl.push_str(&format!(" private sealed class {class_name} : IHtmlVisitor\n"));
2744 decl.push_str(" {\n");
2745
2746 let all_methods = [
2748 "visit_element_start",
2749 "visit_element_end",
2750 "visit_text",
2751 "visit_link",
2752 "visit_image",
2753 "visit_heading",
2754 "visit_code_block",
2755 "visit_code_inline",
2756 "visit_list_item",
2757 "visit_list_start",
2758 "visit_list_end",
2759 "visit_table_start",
2760 "visit_table_row",
2761 "visit_table_end",
2762 "visit_blockquote",
2763 "visit_strong",
2764 "visit_emphasis",
2765 "visit_strikethrough",
2766 "visit_underline",
2767 "visit_subscript",
2768 "visit_superscript",
2769 "visit_mark",
2770 "visit_line_break",
2771 "visit_horizontal_rule",
2772 "visit_custom_element",
2773 "visit_definition_list_start",
2774 "visit_definition_term",
2775 "visit_definition_description",
2776 "visit_definition_list_end",
2777 "visit_form",
2778 "visit_input",
2779 "visit_button",
2780 "visit_audio",
2781 "visit_video",
2782 "visit_iframe",
2783 "visit_details",
2784 "visit_summary",
2785 "visit_figure_start",
2786 "visit_figcaption",
2787 "visit_figure_end",
2788 ];
2789
2790 for method_name in &all_methods {
2792 if let Some(action) = visitor_spec.callbacks.get(*method_name) {
2793 emit_csharp_visitor_method(&mut decl, method_name, action);
2794 } else {
2795 emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
2797 }
2798 }
2799
2800 decl.push_str(" }\n");
2801 class_decls.push(decl);
2802
2803 var_name
2804}
2805
2806fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
2808 let camel_method = method_to_camel(method_name);
2809 let params = match method_name {
2810 "visit_link" => "NodeContext ctx, string href, string text, string title",
2811 "visit_image" => "NodeContext ctx, string src, string alt, string title",
2812 "visit_heading" => "NodeContext ctx, uint level, string text, string id",
2813 "visit_code_block" => "NodeContext ctx, string lang, string code",
2814 "visit_code_inline"
2815 | "visit_strong"
2816 | "visit_emphasis"
2817 | "visit_strikethrough"
2818 | "visit_underline"
2819 | "visit_subscript"
2820 | "visit_superscript"
2821 | "visit_mark"
2822 | "visit_button"
2823 | "visit_summary"
2824 | "visit_figcaption"
2825 | "visit_definition_term"
2826 | "visit_definition_description" => "NodeContext ctx, string text",
2827 "visit_text" => "NodeContext ctx, string text",
2828 "visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
2829 "visit_blockquote" => "NodeContext ctx, string content, ulong depth",
2830 "visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
2831 "visit_custom_element" => "NodeContext ctx, string tagName, string html",
2832 "visit_form" => "NodeContext ctx, string actionUrl, string method",
2833 "visit_input" => "NodeContext ctx, string inputType, string name, string value",
2834 "visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
2835 "visit_details" => "NodeContext ctx, bool isOpen",
2836 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2837 "NodeContext ctx, string output"
2838 }
2839 "visit_list_start" => "NodeContext ctx, bool ordered",
2840 "visit_list_end" => "NodeContext ctx, bool ordered, string output",
2841 "visit_element_start"
2842 | "visit_table_start"
2843 | "visit_definition_list_start"
2844 | "visit_figure_start"
2845 | "visit_line_break"
2846 | "visit_horizontal_rule" => "NodeContext ctx",
2847 _ => "NodeContext ctx",
2848 };
2849
2850 let (action_type, action_value) = match action {
2851 CallbackAction::Skip => ("skip", String::new()),
2852 CallbackAction::Continue => ("continue", String::new()),
2853 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
2854 CallbackAction::Custom { output } => ("custom", escape_csharp(output)),
2855 CallbackAction::CustomTemplate { template } => {
2856 let camel = snake_case_template_to_camel(template);
2857 ("custom_template", escape_csharp(&camel))
2858 }
2859 };
2860
2861 let rendered = crate::template_env::render(
2862 "csharp/visitor_method.jinja",
2863 minijinja::context! {
2864 camel_method => camel_method,
2865 params => params,
2866 action_type => action_type,
2867 action_value => action_value,
2868 },
2869 );
2870 let _ = write!(decl, "{}", rendered);
2871}
2872
2873fn method_to_camel(snake: &str) -> String {
2875 use heck::ToUpperCamelCase;
2876 snake.to_upper_camel_case()
2877}
2878
2879fn snake_case_template_to_camel(template: &str) -> String {
2882 use heck::ToLowerCamelCase;
2883 let mut out = String::with_capacity(template.len());
2884 let mut chars = template.chars().peekable();
2885 while let Some(c) = chars.next() {
2886 if c == '{' {
2887 let mut name = String::new();
2888 while let Some(&nc) = chars.peek() {
2889 if nc == '}' {
2890 chars.next();
2891 break;
2892 }
2893 name.push(nc);
2894 chars.next();
2895 }
2896 out.push('{');
2897 out.push_str(&name.to_lower_camel_case());
2898 out.push('}');
2899 } else {
2900 out.push(c);
2901 }
2902 }
2903 out
2904}
2905
2906fn build_csharp_method_call(
2911 result_var: &str,
2912 method_name: &str,
2913 args: Option<&serde_json::Value>,
2914 class_name: &str,
2915) -> String {
2916 match method_name {
2917 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
2918 "root_node_type" => format!("{result_var}.RootNode.Kind"),
2919 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
2920 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
2921 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
2922 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
2923 "contains_node_type" => {
2924 let node_type = args
2925 .and_then(|a| a.get("node_type"))
2926 .and_then(|v| v.as_str())
2927 .unwrap_or("");
2928 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
2929 }
2930 "find_nodes_by_type" => {
2931 let node_type = args
2932 .and_then(|a| a.get("node_type"))
2933 .and_then(|v| v.as_str())
2934 .unwrap_or("");
2935 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
2936 }
2937 "run_query" => {
2938 let query_source = args
2939 .and_then(|a| a.get("query_source"))
2940 .and_then(|v| v.as_str())
2941 .unwrap_or("");
2942 let language = args
2943 .and_then(|a| a.get("language"))
2944 .and_then(|v| v.as_str())
2945 .unwrap_or("");
2946 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
2947 }
2948 _ => {
2949 use heck::ToUpperCamelCase;
2950 let pascal = method_name.to_upper_camel_case();
2951 format!("{result_var}.{pascal}()")
2952 }
2953 }
2954}
2955
2956fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
2957 if fixture.is_http_test() {
2959 return false;
2960 }
2961 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
2962 let cs_override = call_config
2963 .overrides
2964 .get("csharp")
2965 .or_else(|| e2e_config.call.overrides.get("csharp"));
2966 if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
2968 return true;
2969 }
2970 cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
2973}
2974
2975fn classify_bytes_value_csharp(s: &str) -> String {
2978 if let Some(first) = s.chars().next() {
2981 if first.is_ascii_alphanumeric() || first == '_' {
2982 if let Some(slash_pos) = s.find('/') {
2983 if slash_pos > 0 {
2984 let after_slash = &s[slash_pos + 1..];
2985 if after_slash.contains('.') && !after_slash.is_empty() {
2986 return format!("System.IO.File.ReadAllBytes(\"{}\")", s);
2988 }
2989 }
2990 }
2991 }
2992 }
2993
2994 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
2997 return format!("System.Text.Encoding.UTF8.GetBytes(\"{}\")", escape_csharp(s));
2999 }
3000
3001 format!("System.Convert.FromBase64String(\"{}\")", s)
3005}