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