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