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