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