1use crate::codegen::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{escape_rust, rust_raw_string, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, CorsConfig, Fixture, FixtureGroup, StaticFilesConfig};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions as tv;
15use anyhow::Result;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19pub struct RustE2eCodegen;
21
22impl super::E2eCodegen for RustE2eCodegen {
23 fn generate(
24 &self,
25 groups: &[FixtureGroup],
26 e2e_config: &E2eConfig,
27 alef_config: &AlefConfig,
28 ) -> Result<Vec<GeneratedFile>> {
29 let mut files = Vec::new();
30 let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
31
32 let crate_name = resolve_crate_name(e2e_config, alef_config);
34 let crate_path = resolve_crate_path(e2e_config, &crate_name);
35 let dep_name = crate_name.replace('-', "_");
36
37 let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
40 let needs_serde_json = all_call_configs
41 .flat_map(|c| c.args.iter())
42 .any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
43
44 let needs_mock_server = groups
47 .iter()
48 .flat_map(|g| g.fixtures.iter())
49 .any(|f| !is_skipped(f, "rust") && f.mock_response.is_some());
50
51 let needs_http_tests = groups
53 .iter()
54 .flat_map(|g| g.fixtures.iter())
55 .any(|f| !is_skipped(f, "rust") && f.http.is_some());
56
57 let needs_tower_http = groups
59 .iter()
60 .flat_map(|g| g.fixtures.iter())
61 .filter(|f| !is_skipped(f, "rust"))
62 .filter_map(|f| f.http.as_ref())
63 .filter_map(|h| h.handler.middleware.as_ref())
64 .any(|m| m.cors.is_some() || m.static_files.is_some());
65
66 let any_async_call = std::iter::once(&e2e_config.call)
68 .chain(e2e_config.calls.values())
69 .any(|c| c.r#async);
70 let needs_tokio = needs_mock_server || needs_http_tests || any_async_call;
71
72 let crate_version = resolve_crate_version(e2e_config);
73 files.push(GeneratedFile {
74 path: output_base.join("Cargo.toml"),
75 content: render_cargo_toml(
76 &crate_name,
77 &dep_name,
78 &crate_path,
79 needs_serde_json,
80 needs_mock_server,
81 needs_http_tests,
82 needs_tokio,
83 needs_tower_http,
84 e2e_config.dep_mode,
85 crate_version.as_deref(),
86 &alef_config.crate_config.features,
87 ),
88 generated_header: true,
89 });
90
91 if needs_mock_server {
93 files.push(GeneratedFile {
94 path: output_base.join("tests").join("mock_server.rs"),
95 content: render_mock_server_module(),
96 generated_header: true,
97 });
98 }
99 if needs_mock_server || needs_http_tests {
102 files.push(GeneratedFile {
103 path: output_base.join("src").join("main.rs"),
104 content: render_mock_server_binary(),
105 generated_header: true,
106 });
107 }
108
109 for group in groups {
111 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
112
113 if fixtures.is_empty() {
114 continue;
115 }
116
117 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
118 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
119
120 files.push(GeneratedFile {
121 path: output_base.join("tests").join(filename),
122 content,
123 generated_header: true,
124 });
125 }
126
127 Ok(files)
128 }
129
130 fn language_name(&self) -> &'static str {
131 "rust"
132 }
133}
134
135fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
140 alef_config.crate_config.name.clone()
144}
145
146fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
147 e2e_config
148 .resolve_package("rust")
149 .and_then(|p| p.path.clone())
150 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
151}
152
153fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
154 e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
155}
156
157fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
158 call_config
159 .overrides
160 .get("rust")
161 .and_then(|o| o.function.clone())
162 .unwrap_or_else(|| call_config.function.clone())
163}
164
165fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
166 resolve_module_for_call(&e2e_config.call, dep_name)
167}
168
169fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
170 let overrides = call_config.overrides.get("rust");
173 overrides
174 .and_then(|o| o.crate_name.clone())
175 .or_else(|| overrides.and_then(|o| o.module.clone()))
176 .unwrap_or_else(|| dep_name.to_string())
177}
178
179fn is_skipped(fixture: &Fixture, language: &str) -> bool {
180 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
181}
182
183#[allow(clippy::too_many_arguments)]
188pub fn render_cargo_toml(
189 crate_name: &str,
190 dep_name: &str,
191 crate_path: &str,
192 needs_serde_json: bool,
193 needs_mock_server: bool,
194 needs_http_tests: bool,
195 needs_tokio: bool,
196 needs_tower_http: bool,
197 dep_mode: crate::config::DependencyMode,
198 version: Option<&str>,
199 features: &[String],
200) -> String {
201 let e2e_name = format!("{dep_name}-e2e-rust");
202 let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
206 let features_str = if effective_features.is_empty() {
207 String::new()
208 } else {
209 format!(", default-features = false, features = {:?}", effective_features)
210 };
211 let dep_spec = match dep_mode {
212 crate::config::DependencyMode::Registry => {
213 let ver = version.unwrap_or("0.1.0");
214 if crate_name != dep_name {
215 format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
216 } else if effective_features.is_empty() {
217 format!("{dep_name} = \"{ver}\"")
218 } else {
219 format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
220 }
221 }
222 crate::config::DependencyMode::Local => {
223 if crate_name != dep_name {
224 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
225 } else if effective_features.is_empty() {
226 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
227 } else {
228 format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
229 }
230 }
231 };
232 let effective_needs_serde_json = needs_serde_json || needs_mock_server || needs_http_tests;
236 let serde_line = if effective_needs_serde_json {
237 "\nserde_json = \"1\""
238 } else {
239 ""
240 };
241 let needs_axum = needs_mock_server || needs_http_tests;
250 let mock_lines = if needs_axum {
251 let mut lines = format!(
252 "\naxum = \"{axum}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
253 axum = tv::cargo::AXUM,
254 walkdir = tv::cargo::WALKDIR,
255 );
256 if needs_mock_server {
257 lines.push_str(&format!(
258 "\ntokio-stream = \"{tokio_stream}\"",
259 tokio_stream = tv::cargo::TOKIO_STREAM
260 ));
261 }
262 if needs_http_tests {
263 lines.push_str("\naxum-test = \"20\"\nbytes = \"1\"");
264 }
265 if needs_tower_http {
266 lines.push_str(&format!(
267 "\ntower-http = {{ version = \"{tower_http}\", features = [\"cors\", \"fs\"] }}\ntempfile = \"{tempfile}\"",
268 tower_http = tv::cargo::TOWER_HTTP,
269 tempfile = tv::cargo::TEMPFILE,
270 ));
271 }
272 lines
273 } else {
274 String::new()
275 };
276 let mut machete_ignored: Vec<&str> = Vec::new();
277 if effective_needs_serde_json {
278 machete_ignored.push("\"serde_json\"");
279 }
280 if needs_axum {
281 machete_ignored.push("\"axum\"");
282 machete_ignored.push("\"serde\"");
283 machete_ignored.push("\"walkdir\"");
284 }
285 if needs_mock_server {
286 machete_ignored.push("\"tokio-stream\"");
287 }
288 if needs_http_tests {
289 machete_ignored.push("\"axum-test\"");
290 machete_ignored.push("\"bytes\"");
291 }
292 if needs_tower_http {
293 machete_ignored.push("\"tower-http\"");
294 machete_ignored.push("\"tempfile\"");
295 }
296 let machete_section = if machete_ignored.is_empty() {
297 String::new()
298 } else {
299 format!(
300 "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
301 machete_ignored.join(", ")
302 )
303 };
304 let tokio_line = if needs_tokio {
305 "\ntokio = { version = \"1\", features = [\"full\"] }"
306 } else {
307 ""
308 };
309 let bin_section = if needs_mock_server || needs_http_tests {
310 "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
311 } else {
312 ""
313 };
314 let header = hash::header(CommentStyle::Hash);
315 format!(
316 r#"{header}
317[workspace]
318
319[package]
320name = "{e2e_name}"
321version = "0.1.0"
322edition = "2021"
323license = "MIT"
324publish = false
325{bin_section}
326[dependencies]
327{dep_spec}{serde_line}{mock_lines}{tokio_line}
328{machete_section}"#
329 )
330}
331
332fn render_test_file(
333 category: &str,
334 fixtures: &[&Fixture],
335 e2e_config: &E2eConfig,
336 dep_name: &str,
337 needs_mock_server: bool,
338) -> String {
339 let mut out = String::new();
340 out.push_str(&hash::header(CommentStyle::DoubleSlash));
341 let _ = writeln!(out, "//! E2e tests for category: {category}");
342 let _ = writeln!(out);
343
344 let module = resolve_module(e2e_config, dep_name);
345 let field_resolver = FieldResolver::new(
346 &e2e_config.fields,
347 &e2e_config.fields_optional,
348 &e2e_config.result_fields,
349 &e2e_config.fields_array,
350 );
351
352 let file_has_http = fixtures.iter().any(|f| f.http.is_some());
354 let is_call_based = |f: &Fixture| -> bool {
359 if f.http.is_some() {
360 return false;
361 }
362 let cc = e2e_config.resolve_call(f.call.as_deref());
363 !resolve_function_name_for_call(cc).is_empty()
364 };
365 let file_has_call_based = fixtures.iter().any(|f| is_call_based(f));
366
367 if file_has_call_based {
370 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
371 for fixture in fixtures.iter().filter(|f| is_call_based(f)) {
372 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
373 let fn_name = resolve_function_name_for_call(call_config);
374 let mod_name = resolve_module_for_call(call_config, dep_name);
375 imported.insert((mod_name, fn_name));
376 }
377 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
379 for (mod_name, fn_name) in &imported {
380 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
381 }
382 for (mod_name, fns) in &by_module {
383 if fns.len() == 1 {
384 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
385 } else {
386 let joined = fns.join(", ");
387 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
388 }
389 }
390 }
391
392 if file_has_http {
394 let _ = writeln!(out, "use {module}::{{App, RequestContext}};");
395 }
396
397 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
399 if has_handle_args {
400 let _ = writeln!(out, "use {module}::CrawlConfig;");
401 }
402 for arg in &e2e_config.call.args {
403 if arg.arg_type == "handle" {
404 use heck::ToSnakeCase;
405 let constructor_name = format!("create_{}", arg.name.to_snake_case());
406 let _ = writeln!(out, "use {module}::{constructor_name};");
407 }
408 }
409
410 let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.mock_response.is_some());
412 if file_needs_mock {
413 let _ = writeln!(out, "mod mock_server;");
414 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
415 }
416
417 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
421 if file_needs_visitor {
422 let visitor_trait = resolve_visitor_trait(&module);
423 let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
424 }
425
426 let _ = writeln!(out);
427
428 for fixture in fixtures {
429 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
430 let _ = writeln!(out);
431 }
432
433 if !out.ends_with('\n') {
434 out.push('\n');
435 }
436 out
437}
438
439fn render_test_function(
440 out: &mut String,
441 fixture: &Fixture,
442 e2e_config: &E2eConfig,
443 dep_name: &str,
444 field_resolver: &FieldResolver,
445) {
446 if fixture.http.is_some() {
448 render_http_test_function(out, fixture, dep_name);
449 return;
450 }
451
452 let fn_name = sanitize_ident(&fixture.id);
453 let description = &fixture.description;
454 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
455 let function_name = resolve_function_name_for_call(call_config);
456 let module = resolve_module_for_call(call_config, dep_name);
457
458 if fixture.http.is_none() && fixture.mock_response.is_none() && function_name.is_empty() {
462 let _ = writeln!(out, "#[tokio::test]");
463 let _ = writeln!(out, "async fn test_{fn_name}() {{");
464 let _ = writeln!(out, " // {description}");
465 let _ = writeln!(
466 out,
467 " // TODO: implement when a callable API is available for this fixture type."
468 );
469 let _ = writeln!(out, "}}");
470 return;
471 }
472 let result_var = &call_config.result_var;
473 let has_mock = fixture.mock_response.is_some();
474
475 let is_async = call_config.r#async || has_mock;
477 if is_async {
478 let _ = writeln!(out, "#[tokio::test]");
479 let _ = writeln!(out, "async fn test_{fn_name}() {{");
480 } else {
481 let _ = writeln!(out, "#[test]");
482 let _ = writeln!(out, "fn test_{fn_name}() {{");
483 }
484 let _ = writeln!(out, " // {description}");
485
486 if has_mock {
489 render_mock_server_setup(out, fixture, e2e_config);
490 }
491
492 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
494
495 let rust_overrides = call_config.overrides.get("rust");
497 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
498 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
499
500 let mut arg_exprs: Vec<String> = Vec::new();
502 for arg in &call_config.args {
503 let value = resolve_field(&fixture.input, &arg.field);
504 let var_name = &arg.name;
505 let (bindings, expr) = render_rust_arg(
506 var_name,
507 value,
508 &arg.arg_type,
509 arg.optional,
510 &module,
511 &fixture.id,
512 if has_mock {
513 Some("mock_server.url.as_str()")
514 } else {
515 None
516 },
517 arg.owned,
518 arg.element_type.as_deref(),
519 );
520 for binding in &bindings {
521 let _ = writeln!(out, " {binding}");
522 }
523 let final_expr = if wrap_options_in_some && arg.arg_type == "json_object" {
527 if let Some(rest) = expr.strip_prefix('&') {
528 format!("Some({rest}.clone())")
529 } else {
530 format!("Some({expr})")
531 }
532 } else {
533 expr
534 };
535 arg_exprs.push(final_expr);
536 }
537
538 if let Some(visitor_spec) = &fixture.visitor {
540 let _ = writeln!(out, " struct _TestVisitor;");
541 let _ = writeln!(out, " impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
542 for (method_name, action) in &visitor_spec.callbacks {
543 emit_rust_visitor_method(out, method_name, action);
544 }
545 let _ = writeln!(out, " }}");
546 let _ = writeln!(
547 out,
548 " let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
549 );
550 arg_exprs.push("Some(visitor)".to_string());
551 } else {
552 arg_exprs.extend(extra_args);
555 }
556
557 let args_str = arg_exprs.join(", ");
558
559 let await_suffix = if is_async { ".await" } else { "" };
560
561 let result_is_tree = call_config.result_var == "tree";
562 let result_is_simple = rust_overrides.is_some_and(|o| o.result_is_simple);
565 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
568 let result_is_option = rust_overrides.is_some_and(|o| o.result_is_option);
571
572 if has_error_assertion {
573 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
574 for assertion in &fixture.assertions {
576 render_assertion(
577 out,
578 assertion,
579 result_var,
580 &module,
581 dep_name,
582 true,
583 &[],
584 field_resolver,
585 result_is_tree,
586 result_is_simple,
587 false,
588 false,
589 );
590 }
591 let _ = writeln!(out, "}}");
592 return;
593 }
594
595 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
597
598 let has_usable_assertion = fixture.assertions.iter().any(|a| {
602 if a.assertion_type == "not_error" || a.assertion_type == "error" {
603 return false;
604 }
605 if a.assertion_type == "method_result" {
606 let supported_checks = [
609 "equals",
610 "is_true",
611 "is_false",
612 "greater_than_or_equal",
613 "count_min",
614 "is_error",
615 "contains",
616 "not_empty",
617 "is_empty",
618 ];
619 let check = a.check.as_deref().unwrap_or("is_true");
620 if a.method.is_none() || !supported_checks.contains(&check) {
621 return false;
622 }
623 }
624 match &a.field {
625 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
626 _ => true,
627 }
628 });
629
630 let result_binding = if has_usable_assertion {
631 result_var.to_string()
632 } else {
633 "_".to_string()
634 };
635
636 let has_field_access = fixture
640 .assertions
641 .iter()
642 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
643 let only_emptiness_checks = !has_field_access
644 && fixture.assertions.iter().all(|a| {
645 matches!(
646 a.assertion_type.as_str(),
647 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
648 )
649 });
650
651 let returns_result = rust_overrides
654 .and_then(|o| o.returns_result)
655 .unwrap_or(call_config.returns_result);
656
657 let unwrap_suffix = if returns_result {
658 ".expect(\"should succeed\")"
659 } else {
660 ""
661 };
662 if only_emptiness_checks || !returns_result {
663 let _ = writeln!(
665 out,
666 " let {result_binding} = {function_name}({args_str}){await_suffix};"
667 );
668 } else if has_not_error || !fixture.assertions.is_empty() {
669 let _ = writeln!(
670 out,
671 " let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
672 );
673 } else {
674 let _ = writeln!(
675 out,
676 " let {result_binding} = {function_name}({args_str}){await_suffix};"
677 );
678 }
679
680 let string_assertion_types = [
686 "equals",
687 "contains",
688 "contains_all",
689 "contains_any",
690 "not_contains",
691 "starts_with",
692 "ends_with",
693 "min_length",
694 "max_length",
695 "matches_regex",
696 ];
697 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
699 for assertion in &fixture.assertions {
700 if let Some(f) = &assertion.field {
701 if !f.is_empty()
702 && string_assertion_types.contains(&assertion.assertion_type.as_str())
703 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
704 {
705 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
708 if !is_string_assertion {
709 continue;
710 }
711 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
712 let _ = writeln!(out, " {binding}");
713 unwrapped_fields.push((f.clone(), local_var));
714 }
715 }
716 }
717 }
718 }
719
720 for assertion in &fixture.assertions {
722 if assertion.assertion_type == "not_error" {
723 continue;
725 }
726 render_assertion(
727 out,
728 assertion,
729 result_var,
730 &module,
731 dep_name,
732 false,
733 &unwrapped_fields,
734 field_resolver,
735 result_is_tree,
736 result_is_simple,
737 result_is_vec,
738 result_is_option,
739 );
740 }
741
742 let _ = writeln!(out, "}}");
743}
744
745#[allow(clippy::too_many_arguments)]
750fn render_rust_arg(
751 name: &str,
752 value: &serde_json::Value,
753 arg_type: &str,
754 optional: bool,
755 module: &str,
756 fixture_id: &str,
757 mock_base_url: Option<&str>,
758 owned: bool,
759 element_type: Option<&str>,
760) -> (Vec<String>, String) {
761 if arg_type == "mock_url" {
762 let lines = vec![format!(
763 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
764 )];
765 return (lines, format!("&{name}"));
766 }
767 if arg_type == "base_url" {
769 if let Some(url_expr) = mock_base_url {
770 return (vec![], url_expr.to_string());
771 }
772 }
774 if arg_type == "handle" {
775 use heck::ToSnakeCase;
779 let constructor_name = format!("create_{}", name.to_snake_case());
780 let mut lines = Vec::new();
781 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
782 lines.push(format!(
783 "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
784 ));
785 } else {
786 let json_literal = serde_json::to_string(value).unwrap_or_default();
788 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
789 lines.push(format!(
790 "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
791 ));
792 lines.push(format!(
793 "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
794 ));
795 }
796 return (lines, format!("&{name}"));
797 }
798 if arg_type == "json_object" {
799 return render_json_object_arg(name, value, optional, owned, element_type, module);
800 }
801 if arg_type == "bytes" && !optional {
806 if let Some(raw) = value.as_str() {
807 if matches!(classify_bytes_value(raw), BytesKind::FilePath) {
808 let lines = vec![format!(
809 "let {name} = std::fs::read(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../test_documents/\", \"{raw}\")).expect(\"fixture file should exist\");"
810 )];
811 return (lines, format!("&{name}"));
812 }
813 }
814 }
815 if arg_type == "bytes" && optional {
816 if let Some(raw) = value.as_str() {
817 if matches!(classify_bytes_value(raw), BytesKind::FilePath) {
818 let lines = vec![format!(
819 "let {name} = Some(std::fs::read(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../test_documents/\", \"{raw}\")).expect(\"fixture file should exist\"));"
820 )];
821 return (lines, format!("{name}.as_deref().map(|v| v.as_slice())"));
822 }
823 }
824 }
825 if value.is_null() && !optional {
826 let default_val = match arg_type {
828 "string" => "String::new()".to_string(),
829 "int" | "integer" => "0".to_string(),
830 "float" | "number" => "0.0_f64".to_string(),
831 "bool" | "boolean" => "false".to_string(),
832 _ => "Default::default()".to_string(),
833 };
834 let expr = if arg_type == "string" {
836 format!("&{name}")
837 } else {
838 name.to_string()
839 };
840 return (vec![format!("let {name} = {default_val};")], expr);
841 }
842 let literal = json_to_rust_literal(value, arg_type);
843 let pass_by_ref = arg_type == "bytes";
846 let optional_expr = |n: &str| {
847 if arg_type == "string" {
848 format!("{n}.as_deref()")
849 } else if arg_type == "bytes" {
850 format!("{n}.as_deref().map(|v| v.as_slice())")
851 } else {
852 n.to_string()
856 }
857 };
858 let expr = |n: &str| {
859 if arg_type == "bytes" {
860 format!("{n}.as_bytes()")
861 } else if pass_by_ref {
862 format!("&{n}")
863 } else {
864 n.to_string()
865 }
866 };
867 if optional && value.is_null() {
868 let none_decl = match arg_type {
869 "string" => format!("let {name}: Option<String> = None;"),
870 "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
871 _ => format!("let {name} = None;"),
872 };
873 (vec![none_decl], optional_expr(name))
874 } else if optional {
875 (vec![format!("let {name} = Some({literal});")], optional_expr(name))
876 } else {
877 (vec![format!("let {name} = {literal};")], expr(name))
878 }
879}
880
881#[derive(Debug, PartialEq, Eq)]
886enum BytesKind {
887 FilePath,
889 InlineText,
891 Base64,
894}
895
896fn classify_bytes_value(s: &str) -> BytesKind {
905 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
906 return BytesKind::InlineText;
907 }
908 let first = s.chars().next().unwrap_or('\0');
909 if first.is_ascii_alphanumeric() || first == '_' {
910 if let Some(slash_pos) = s.find('/') {
911 if slash_pos > 0 {
912 let after_slash = &s[slash_pos + 1..];
913 if after_slash.contains('.') && !after_slash.is_empty() {
914 return BytesKind::FilePath;
915 }
916 }
917 }
918 }
919 BytesKind::Base64
920}
921
922fn render_json_object_arg(
930 name: &str,
931 value: &serde_json::Value,
932 optional: bool,
933 owned: bool,
934 element_type: Option<&str>,
935 _module: &str,
936) -> (Vec<String>, String) {
937 let pass_by_ref = !owned;
939
940 if value.is_null() && optional {
941 let expr = if pass_by_ref {
943 format!("&{name}")
944 } else {
945 name.to_string()
946 };
947 return (vec![format!("let {name} = Default::default();")], expr);
948 }
949
950 let normalized = super::normalize_json_keys_to_snake_case(value);
953 let json_literal = json_value_to_macro_literal(&normalized);
955 let mut lines = Vec::new();
956 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
957
958 let deser_expr = if let Some(elem) = element_type {
961 format!("serde_json::from_value::<Vec<{elem}>>({name}_json).unwrap()")
962 } else {
963 format!("serde_json::from_value({name}_json).unwrap()")
964 };
965
966 lines.push(format!("let {name} = {deser_expr};"));
969 let expr = if pass_by_ref {
970 format!("&{name}")
971 } else {
972 name.to_string()
973 };
974 (lines, expr)
975}
976
977fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
979 match value {
980 serde_json::Value::Null => "null".to_string(),
981 serde_json::Value::Bool(b) => format!("{b}"),
982 serde_json::Value::Number(n) => n.to_string(),
983 serde_json::Value::String(s) => {
984 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
985 format!("\"{escaped}\"")
986 }
987 serde_json::Value::Array(arr) => {
988 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
989 format!("[{}]", items.join(", "))
990 }
991 serde_json::Value::Object(obj) => {
992 let entries: Vec<String> = obj
993 .iter()
994 .map(|(k, v)| {
995 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
996 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
997 })
998 .collect();
999 format!("{{{}}}", entries.join(", "))
1000 }
1001 }
1002}
1003
1004fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
1005 match value {
1006 serde_json::Value::Null => "None".to_string(),
1007 serde_json::Value::Bool(b) => format!("{b}"),
1008 serde_json::Value::Number(n) => {
1009 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
1010 if let Some(f) = n.as_f64() {
1011 return format!("{f}_f64");
1012 }
1013 }
1014 n.to_string()
1015 }
1016 serde_json::Value::String(s) => rust_raw_string(s),
1017 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
1018 let json_str = serde_json::to_string(value).unwrap_or_default();
1019 let literal = rust_raw_string(&json_str);
1020 format!("serde_json::from_str({literal}).unwrap()")
1021 }
1022 }
1023}
1024
1025enum ServerCall<'a> {
1031 Shorthand(&'a str),
1033 AxumMethod(&'a str),
1035}
1036
1037enum RouteRegistration<'a> {
1039 Shorthand(&'a str),
1041 Explicit(&'a str),
1043}
1044
1045fn render_http_test_function(out: &mut String, fixture: &Fixture, dep_name: &str) {
1051 let http = match &fixture.http {
1052 Some(h) => h,
1053 None => return,
1054 };
1055
1056 let fn_name = sanitize_ident(&fixture.id);
1057 let description = &fixture.description;
1058
1059 let route = &http.handler.route;
1060
1061 let route_reg = match http.handler.method.to_lowercase().as_str() {
1064 "get" => RouteRegistration::Shorthand("get"),
1065 "post" => RouteRegistration::Shorthand("post"),
1066 "put" => RouteRegistration::Shorthand("put"),
1067 "patch" => RouteRegistration::Shorthand("patch"),
1068 "delete" => RouteRegistration::Shorthand("delete"),
1069 "head" => RouteRegistration::Explicit("Head"),
1070 "options" => RouteRegistration::Explicit("Options"),
1071 "trace" => RouteRegistration::Explicit("Trace"),
1072 _ => RouteRegistration::Shorthand("get"),
1073 };
1074
1075 let server_call = match http.request.method.to_uppercase().as_str() {
1078 "GET" => ServerCall::Shorthand("get"),
1079 "POST" => ServerCall::Shorthand("post"),
1080 "PUT" => ServerCall::Shorthand("put"),
1081 "PATCH" => ServerCall::Shorthand("patch"),
1082 "DELETE" => ServerCall::Shorthand("delete"),
1083 "HEAD" => ServerCall::AxumMethod("HEAD"),
1084 "OPTIONS" => ServerCall::AxumMethod("OPTIONS"),
1085 "TRACE" => ServerCall::AxumMethod("TRACE"),
1086 _ => ServerCall::Shorthand("get"),
1087 };
1088
1089 let req_path = &http.request.path;
1090 let status = http.expected_response.status_code;
1091
1092 let body_str = match &http.expected_response.body {
1094 Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1095 None => String::new(),
1096 };
1097 let body_literal = rust_raw_string(&body_str);
1098
1099 let req_body_str = match &http.request.body {
1101 Some(b) => serde_json::to_string(b).unwrap_or_else(|_| "{}".to_string()),
1102 None => String::new(),
1103 };
1104 let has_req_body = !req_body_str.is_empty();
1105
1106 let middleware = http.handler.middleware.as_ref();
1108 let cors_cfg: Option<&CorsConfig> = middleware.and_then(|m| m.cors.as_ref());
1109 let static_files_cfgs: Option<&Vec<StaticFilesConfig>> = middleware.and_then(|m| m.static_files.as_ref());
1110 let has_static_files = static_files_cfgs.is_some_and(|v| !v.is_empty());
1111
1112 let _ = writeln!(out, "#[tokio::test]");
1113 let _ = writeln!(out, "async fn test_{fn_name}() {{");
1114 let _ = writeln!(out, " // {description}");
1115
1116 if has_static_files {
1118 render_static_files_test(out, fixture, static_files_cfgs.unwrap(), &server_call, req_path, status);
1119 return;
1120 }
1121
1122 let _ = writeln!(out, " let expected_body = {body_literal}.to_string();");
1124 let _ = writeln!(out, " let mut app = {dep_name}::App::new();");
1125
1126 match &route_reg {
1128 RouteRegistration::Shorthand(method) => {
1129 let _ = writeln!(
1130 out,
1131 " app.route({dep_name}::{method}({route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1132 );
1133 }
1134 RouteRegistration::Explicit(variant) => {
1135 let _ = writeln!(
1136 out,
1137 " app.route({dep_name}::RouteBuilder::new({dep_name}::Method::{variant}, {route:?}), move |_ctx: {dep_name}::RequestContext| {{"
1138 );
1139 }
1140 }
1141 let _ = writeln!(out, " let body = expected_body.clone();");
1142 let _ = writeln!(out, " async move {{");
1143 let _ = writeln!(out, " Ok(axum::http::Response::builder()");
1144 let _ = writeln!(out, " .status({status}u16)");
1145 let _ = writeln!(out, " .header(\"content-type\", \"application/json\")");
1146 let _ = writeln!(out, " .body(axum::body::Body::from(body))");
1147 let _ = writeln!(out, " .unwrap())");
1148 let _ = writeln!(out, " }}");
1149 let _ = writeln!(out, " }}).unwrap();");
1150
1151 let _ = writeln!(out, " let router = app.into_router().unwrap();");
1153 if let Some(cors) = cors_cfg {
1154 render_cors_layer(out, cors);
1155 }
1156 let _ = writeln!(out, " let server = axum_test::TestServer::new(router);");
1157
1158 match &server_call {
1160 ServerCall::Shorthand(method) => {
1161 let _ = writeln!(out, " let response = server.{method}({req_path:?})");
1162 }
1163 ServerCall::AxumMethod(method) => {
1164 let _ = writeln!(
1165 out,
1166 " let response = server.method(axum::http::Method::{method}, {req_path:?})"
1167 );
1168 }
1169 }
1170
1171 for (name, value) in &http.request.headers {
1173 let n = rust_raw_string(name);
1174 let v = rust_raw_string(value);
1175 let _ = writeln!(out, " .add_header({n}, {v})");
1176 }
1177
1178 if has_req_body {
1180 let req_body_literal = rust_raw_string(&req_body_str);
1181 let _ = writeln!(
1182 out,
1183 " .bytes(bytes::Bytes::copy_from_slice({req_body_literal}.as_bytes()))"
1184 );
1185 }
1186
1187 let _ = writeln!(out, " .await;");
1188
1189 if cors_cfg.is_some() && (200..300).contains(&status) {
1193 let _ = writeln!(
1194 out,
1195 " assert!(response.status_code().is_success(), \"expected CORS success status, got {{}}\", response.status_code());"
1196 );
1197 } else {
1198 let _ = writeln!(out, " assert_eq!(response.status_code().as_u16(), {status}u16);");
1199 }
1200
1201 let _ = writeln!(out, "}}");
1202}
1203
1204fn render_cors_layer(out: &mut String, cors: &CorsConfig) {
1209 let _ = writeln!(
1210 out,
1211 " // Apply CorsLayer from tower-http based on fixture CORS config."
1212 );
1213 let _ = writeln!(out, " use tower_http::cors::CorsLayer;");
1214 let _ = writeln!(out, " use axum::http::{{HeaderName, HeaderValue, Method}};");
1215 let _ = writeln!(out, " let cors_layer = CorsLayer::new()");
1216
1217 if cors.allow_origins.is_empty() {
1219 let _ = writeln!(out, " .allow_origin(tower_http::cors::Any)");
1220 } else {
1221 let _ = writeln!(out, " .allow_origin([");
1222 for origin in &cors.allow_origins {
1223 let _ = writeln!(out, " \"{origin}\".parse::<HeaderValue>().unwrap(),");
1224 }
1225 let _ = writeln!(out, " ])");
1226 }
1227
1228 if cors.allow_methods.is_empty() {
1230 let _ = writeln!(out, " .allow_methods(tower_http::cors::Any)");
1231 } else {
1232 let methods: Vec<String> = cors
1233 .allow_methods
1234 .iter()
1235 .map(|m| format!("Method::{}", m.to_uppercase()))
1236 .collect();
1237 let _ = writeln!(out, " .allow_methods([{}])", methods.join(", "));
1238 }
1239
1240 if cors.allow_headers.is_empty() {
1242 let _ = writeln!(out, " .allow_headers(tower_http::cors::Any)");
1243 } else {
1244 let headers: Vec<String> = cors
1245 .allow_headers
1246 .iter()
1247 .map(|h| {
1248 let lower = h.to_lowercase();
1249 match lower.as_str() {
1250 "content-type" => "axum::http::header::CONTENT_TYPE".to_string(),
1251 "authorization" => "axum::http::header::AUTHORIZATION".to_string(),
1252 "accept" => "axum::http::header::ACCEPT".to_string(),
1253 _ => format!("HeaderName::from_static(\"{lower}\")"),
1254 }
1255 })
1256 .collect();
1257 let _ = writeln!(out, " .allow_headers([{}])", headers.join(", "));
1258 }
1259
1260 if let Some(secs) = cors.max_age {
1262 let _ = writeln!(out, " .max_age(std::time::Duration::from_secs({secs}));");
1263 } else {
1264 let _ = writeln!(out, " ;");
1265 }
1266
1267 let _ = writeln!(out, " let router = router.layer(cors_layer);");
1268}
1269
1270fn render_static_files_test(
1275 out: &mut String,
1276 fixture: &Fixture,
1277 cfgs: &[StaticFilesConfig],
1278 server_call: &ServerCall<'_>,
1279 req_path: &str,
1280 status: u16,
1281) {
1282 let http = fixture.http.as_ref().unwrap();
1283
1284 let _ = writeln!(out, " use tower_http::services::ServeDir;");
1285 let _ = writeln!(out, " use axum::Router;");
1286 let _ = writeln!(out, " let tmp_dir = tempfile::tempdir().expect(\"tmp dir\");");
1287
1288 let _ = writeln!(out, " let mut router = Router::new();");
1290 for cfg in cfgs {
1291 for file in &cfg.files {
1292 let file_path = file.path.replace('\\', "/");
1293 let content = rust_raw_string(&file.content);
1294 if file_path.contains('/') {
1295 let parent: String = file_path.rsplitn(2, '/').last().unwrap_or("").to_string();
1296 let _ = writeln!(
1297 out,
1298 " std::fs::create_dir_all(tmp_dir.path().join(\"{parent}\")).unwrap();"
1299 );
1300 }
1301 let _ = writeln!(
1302 out,
1303 " std::fs::write(tmp_dir.path().join(\"{file_path}\"), {content}).unwrap();"
1304 );
1305 }
1306 let prefix = &cfg.route_prefix;
1307 let serve_dir_expr = if cfg.index_file {
1308 "ServeDir::new(tmp_dir.path()).append_index_html_on_directories(true)".to_string()
1309 } else {
1310 "ServeDir::new(tmp_dir.path())".to_string()
1311 };
1312 let _ = writeln!(out, " router = router.nest_service({prefix:?}, {serve_dir_expr});");
1313 }
1314
1315 let _ = writeln!(out, " let server = axum_test::TestServer::new(router);");
1316
1317 match server_call {
1319 ServerCall::Shorthand(method) => {
1320 let _ = writeln!(out, " let response = server.{method}({req_path:?})");
1321 }
1322 ServerCall::AxumMethod(method) => {
1323 let _ = writeln!(
1324 out,
1325 " let response = server.method(axum::http::Method::{method}, {req_path:?})"
1326 );
1327 }
1328 }
1329
1330 for (name, value) in &http.request.headers {
1332 let n = rust_raw_string(name);
1333 let v = rust_raw_string(value);
1334 let _ = writeln!(out, " .add_header({n}, {v})");
1335 }
1336
1337 let _ = writeln!(out, " .await;");
1338 let _ = writeln!(out, " assert_eq!(response.status_code().as_u16(), {status}u16);");
1339 let _ = writeln!(out, "}}");
1340}
1341
1342fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
1352 let mock = match fixture.mock_response.as_ref() {
1353 Some(m) => m,
1354 None => return,
1355 };
1356
1357 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
1359 let path = call_config.path.as_deref().unwrap_or("/");
1360 let method = call_config.method.as_deref().unwrap_or("POST");
1361
1362 let status = mock.status;
1363
1364 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
1366 header_entries.sort_by(|a, b| a.0.cmp(b.0));
1367 let render_headers = |out: &mut String| {
1368 let _ = writeln!(out, " headers: vec![");
1369 for (name, value) in &header_entries {
1370 let n = rust_raw_string(name);
1371 let v = rust_raw_string(value);
1372 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
1373 }
1374 let _ = writeln!(out, " ],");
1375 };
1376
1377 if let Some(chunks) = &mock.stream_chunks {
1378 let _ = writeln!(out, " let mock_route = MockRoute {{");
1380 let _ = writeln!(out, " path: \"{path}\",");
1381 let _ = writeln!(out, " method: \"{method}\",");
1382 let _ = writeln!(out, " status: {status},");
1383 let _ = writeln!(out, " body: String::new(),");
1384 let _ = writeln!(out, " stream_chunks: vec![");
1385 for chunk in chunks {
1386 let chunk_str = match chunk {
1387 serde_json::Value::String(s) => rust_raw_string(s),
1388 other => {
1389 let s = serde_json::to_string(other).unwrap_or_default();
1390 rust_raw_string(&s)
1391 }
1392 };
1393 let _ = writeln!(out, " {chunk_str}.to_string(),");
1394 }
1395 let _ = writeln!(out, " ],");
1396 render_headers(out);
1397 let _ = writeln!(out, " }};");
1398 } else {
1399 let body_str = match &mock.body {
1401 Some(b) => {
1402 let s = serde_json::to_string(b).unwrap_or_default();
1403 rust_raw_string(&s)
1404 }
1405 None => rust_raw_string("{}"),
1406 };
1407 let _ = writeln!(out, " let mock_route = MockRoute {{");
1408 let _ = writeln!(out, " path: \"{path}\",");
1409 let _ = writeln!(out, " method: \"{method}\",");
1410 let _ = writeln!(out, " status: {status},");
1411 let _ = writeln!(out, " body: {body_str}.to_string(),");
1412 let _ = writeln!(out, " stream_chunks: vec![],");
1413 render_headers(out);
1414 let _ = writeln!(out, " }};");
1415 }
1416
1417 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
1418}
1419
1420pub fn render_mock_server_module() -> String {
1422 hash::header(CommentStyle::DoubleSlash)
1425 + r#"//
1426// Minimal axum-based mock HTTP server for e2e tests.
1427
1428use std::net::SocketAddr;
1429use std::sync::Arc;
1430
1431use axum::Router;
1432use axum::body::Body;
1433use axum::extract::State;
1434use axum::http::{Request, StatusCode};
1435use axum::response::{IntoResponse, Response};
1436use tokio::net::TcpListener;
1437
1438/// A single mock route: match by path + method, return a configured response.
1439#[derive(Clone, Debug)]
1440pub struct MockRoute {
1441 /// URL path to match, e.g. `"/v1/chat/completions"`.
1442 pub path: &'static str,
1443 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
1444 pub method: &'static str,
1445 /// HTTP status code to return.
1446 pub status: u16,
1447 /// Response body JSON string (used when `stream_chunks` is empty).
1448 pub body: String,
1449 /// Ordered SSE data payloads for streaming responses.
1450 /// Each entry becomes `data: <chunk>\n\n` in the response.
1451 /// A final `data: [DONE]\n\n` is always appended.
1452 pub stream_chunks: Vec<String>,
1453 /// Response headers to apply (name, value) pairs.
1454 /// Multiple entries with the same name produce multiple header lines.
1455 pub headers: Vec<(String, String)>,
1456}
1457
1458struct ServerState {
1459 routes: Vec<MockRoute>,
1460}
1461
1462pub struct MockServer {
1463 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
1464 pub url: String,
1465 handle: tokio::task::JoinHandle<()>,
1466}
1467
1468impl MockServer {
1469 /// Start a mock server with the given routes. Binds to a random port on
1470 /// localhost and returns immediately once the server is listening.
1471 pub async fn start(routes: Vec<MockRoute>) -> Self {
1472 let state = Arc::new(ServerState { routes });
1473
1474 let app = Router::new().fallback(handle_request).with_state(state);
1475
1476 let listener = TcpListener::bind("127.0.0.1:0")
1477 .await
1478 .expect("Failed to bind mock server port");
1479 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
1480 let url = format!("http://{addr}");
1481
1482 let handle = tokio::spawn(async move {
1483 axum::serve(listener, app).await.expect("Mock server failed");
1484 });
1485
1486 MockServer { url, handle }
1487 }
1488
1489 /// Stop the mock server.
1490 pub fn shutdown(self) {
1491 self.handle.abort();
1492 }
1493}
1494
1495impl Drop for MockServer {
1496 fn drop(&mut self) {
1497 self.handle.abort();
1498 }
1499}
1500
1501async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
1502 let path = req.uri().path().to_owned();
1503 let method = req.method().as_str().to_uppercase();
1504
1505 for route in &state.routes {
1506 if route.path == path && route.method.to_uppercase() == method {
1507 let status =
1508 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1509
1510 if !route.stream_chunks.is_empty() {
1511 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
1512 let mut sse = String::new();
1513 for chunk in &route.stream_chunks {
1514 sse.push_str("data: ");
1515 sse.push_str(chunk);
1516 sse.push_str("\n\n");
1517 }
1518 sse.push_str("data: [DONE]\n\n");
1519
1520 let mut builder = Response::builder()
1521 .status(status)
1522 .header("content-type", "text/event-stream")
1523 .header("cache-control", "no-cache");
1524 for (name, value) in &route.headers {
1525 builder = builder.header(name, value);
1526 }
1527 return builder.body(Body::from(sse)).unwrap().into_response();
1528 }
1529
1530 let mut builder =
1531 Response::builder().status(status).header("content-type", "application/json");
1532 for (name, value) in &route.headers {
1533 builder = builder.header(name, value);
1534 }
1535 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
1536 }
1537 }
1538
1539 // No matching route → 404.
1540 Response::builder()
1541 .status(StatusCode::NOT_FOUND)
1542 .body(Body::from(format!("No mock route for {method} {path}")))
1543 .unwrap()
1544 .into_response()
1545}
1546"#
1547}
1548
1549pub fn render_mock_server_binary() -> String {
1561 hash::header(CommentStyle::DoubleSlash)
1562 + r#"//
1563// Standalone mock HTTP server binary for cross-language e2e tests.
1564// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
1565//
1566// Usage: mock-server [fixtures-dir]
1567// fixtures-dir defaults to "../../fixtures"
1568//
1569// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
1570// then blocks until stdin is closed (parent process exit triggers cleanup).
1571
1572use std::collections::HashMap;
1573use std::io::{self, BufRead};
1574use std::net::SocketAddr;
1575use std::path::Path;
1576use std::sync::Arc;
1577
1578use axum::Router;
1579use axum::body::Body;
1580use axum::extract::State;
1581use axum::http::{Request, StatusCode};
1582use axum::response::{IntoResponse, Response};
1583use serde::Deserialize;
1584use tokio::net::TcpListener;
1585
1586// ---------------------------------------------------------------------------
1587// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1588// Supports both schemas:
1589// liter-llm: mock_response: { status, body, stream_chunks }
1590// spikard: http.expected_response: { status_code, body, headers }
1591// ---------------------------------------------------------------------------
1592
1593#[derive(Debug, Deserialize)]
1594struct MockResponse {
1595 status: u16,
1596 #[serde(default)]
1597 body: Option<serde_json::Value>,
1598 #[serde(default)]
1599 stream_chunks: Option<Vec<serde_json::Value>>,
1600 #[serde(default)]
1601 headers: HashMap<String, String>,
1602}
1603
1604#[derive(Debug, Deserialize)]
1605struct HttpExpectedResponse {
1606 status_code: u16,
1607 #[serde(default)]
1608 body: Option<serde_json::Value>,
1609 #[serde(default)]
1610 headers: HashMap<String, String>,
1611}
1612
1613#[derive(Debug, Deserialize)]
1614struct HttpFixture {
1615 expected_response: HttpExpectedResponse,
1616}
1617
1618#[derive(Debug, Deserialize)]
1619struct Fixture {
1620 id: String,
1621 #[serde(default)]
1622 mock_response: Option<MockResponse>,
1623 #[serde(default)]
1624 http: Option<HttpFixture>,
1625}
1626
1627impl Fixture {
1628 /// Bridge both schemas into a unified MockResponse.
1629 fn as_mock_response(&self) -> Option<MockResponse> {
1630 if let Some(mock) = &self.mock_response {
1631 return Some(MockResponse {
1632 status: mock.status,
1633 body: mock.body.clone(),
1634 stream_chunks: mock.stream_chunks.clone(),
1635 headers: mock.headers.clone(),
1636 });
1637 }
1638 if let Some(http) = &self.http {
1639 return Some(MockResponse {
1640 status: http.expected_response.status_code,
1641 body: http.expected_response.body.clone(),
1642 stream_chunks: None,
1643 headers: http.expected_response.headers.clone(),
1644 });
1645 }
1646 None
1647 }
1648}
1649
1650// ---------------------------------------------------------------------------
1651// Route table
1652// ---------------------------------------------------------------------------
1653
1654#[derive(Clone, Debug)]
1655struct MockRoute {
1656 status: u16,
1657 body: String,
1658 stream_chunks: Vec<String>,
1659 headers: Vec<(String, String)>,
1660}
1661
1662type RouteTable = Arc<HashMap<String, MockRoute>>;
1663
1664// ---------------------------------------------------------------------------
1665// Axum handler
1666// ---------------------------------------------------------------------------
1667
1668async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1669 let path = req.uri().path().to_owned();
1670
1671 // Try exact match first
1672 if let Some(route) = routes.get(&path) {
1673 return serve_route(route);
1674 }
1675
1676 // Try prefix match: find a route that is a prefix of the request path
1677 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1678 for (route_path, route) in routes.iter() {
1679 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1680 return serve_route(route);
1681 }
1682 }
1683
1684 Response::builder()
1685 .status(StatusCode::NOT_FOUND)
1686 .body(Body::from(format!("No mock route for {path}")))
1687 .unwrap()
1688 .into_response()
1689}
1690
1691fn serve_route(route: &MockRoute) -> Response {
1692 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1693
1694 if !route.stream_chunks.is_empty() {
1695 let mut sse = String::new();
1696 for chunk in &route.stream_chunks {
1697 sse.push_str("data: ");
1698 sse.push_str(chunk);
1699 sse.push_str("\n\n");
1700 }
1701 sse.push_str("data: [DONE]\n\n");
1702
1703 let mut builder = Response::builder()
1704 .status(status)
1705 .header("content-type", "text/event-stream")
1706 .header("cache-control", "no-cache");
1707 for (name, value) in &route.headers {
1708 builder = builder.header(name, value);
1709 }
1710 return builder.body(Body::from(sse)).unwrap().into_response();
1711 }
1712
1713 // Only set the default content-type if the fixture does not override it.
1714 // Use application/json when the body looks like JSON (starts with { or [),
1715 // otherwise fall back to text/plain to avoid clients failing JSON-decode.
1716 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
1717 let mut builder = Response::builder().status(status);
1718 if !has_content_type {
1719 let trimmed = route.body.trim_start();
1720 let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
1721 "application/json"
1722 } else {
1723 "text/plain"
1724 };
1725 builder = builder.header("content-type", default_ct);
1726 }
1727 for (name, value) in &route.headers {
1728 // Skip content-encoding headers — the mock server returns uncompressed bodies.
1729 // Sending a content-encoding without actually encoding the body would cause
1730 // clients to fail decompression.
1731 if name.to_lowercase() == "content-encoding" {
1732 continue;
1733 }
1734 // The <<absent>> sentinel means this header must NOT be present in the
1735 // real server response — do not emit it from the mock server either.
1736 if value == "<<absent>>" {
1737 continue;
1738 }
1739 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
1740 // assert the header value matches the UUID pattern.
1741 if value == "<<uuid>>" {
1742 let uuid = format!(
1743 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
1744 rand_u32(),
1745 rand_u16(),
1746 rand_u16() & 0x0fff,
1747 (rand_u16() & 0x3fff) | 0x8000,
1748 rand_u48(),
1749 );
1750 builder = builder.header(name, uuid);
1751 continue;
1752 }
1753 builder = builder.header(name, value);
1754 }
1755 builder.body(Body::from(route.body.clone())).unwrap().into_response()
1756}
1757
1758/// Generate a pseudo-random u32 using the current time nanoseconds.
1759fn rand_u32() -> u32 {
1760 use std::time::{SystemTime, UNIX_EPOCH};
1761 let ns = SystemTime::now()
1762 .duration_since(UNIX_EPOCH)
1763 .map(|d| d.subsec_nanos())
1764 .unwrap_or(0);
1765 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
1766}
1767
1768fn rand_u16() -> u16 {
1769 (rand_u32() & 0xffff) as u16
1770}
1771
1772fn rand_u48() -> u64 {
1773 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
1774}
1775
1776// ---------------------------------------------------------------------------
1777// Fixture loading
1778// ---------------------------------------------------------------------------
1779
1780fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1781 let mut routes = HashMap::new();
1782 load_routes_recursive(fixtures_dir, &mut routes);
1783 routes
1784}
1785
1786fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1787 let entries = match std::fs::read_dir(dir) {
1788 Ok(e) => e,
1789 Err(err) => {
1790 eprintln!("warning: cannot read directory {}: {err}", dir.display());
1791 return;
1792 }
1793 };
1794
1795 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1796 paths.sort();
1797
1798 for path in paths {
1799 if path.is_dir() {
1800 load_routes_recursive(&path, routes);
1801 } else if path.extension().is_some_and(|ext| ext == "json") {
1802 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1803 if filename == "schema.json" || filename.starts_with('_') {
1804 continue;
1805 }
1806 let content = match std::fs::read_to_string(&path) {
1807 Ok(c) => c,
1808 Err(err) => {
1809 eprintln!("warning: cannot read {}: {err}", path.display());
1810 continue;
1811 }
1812 };
1813 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1814 match serde_json::from_str(&content) {
1815 Ok(v) => v,
1816 Err(err) => {
1817 eprintln!("warning: cannot parse {}: {err}", path.display());
1818 continue;
1819 }
1820 }
1821 } else {
1822 match serde_json::from_str::<Fixture>(&content) {
1823 Ok(f) => vec![f],
1824 Err(err) => {
1825 eprintln!("warning: cannot parse {}: {err}", path.display());
1826 continue;
1827 }
1828 }
1829 };
1830
1831 for fixture in fixtures {
1832 if let Some(mock) = fixture.as_mock_response() {
1833 let route_path = format!("/fixtures/{}", fixture.id);
1834 let body = mock
1835 .body
1836 .as_ref()
1837 .map(|b| match b {
1838 // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
1839 // fixtures. Return the raw value so clients receive the string itself,
1840 // not its JSON-encoded form with extra surrounding quotes.
1841 serde_json::Value::String(s) => s.clone(),
1842 other => serde_json::to_string(other).unwrap_or_default(),
1843 })
1844 .unwrap_or_default();
1845 let stream_chunks = mock
1846 .stream_chunks
1847 .unwrap_or_default()
1848 .into_iter()
1849 .map(|c| match c {
1850 serde_json::Value::String(s) => s,
1851 other => serde_json::to_string(&other).unwrap_or_default(),
1852 })
1853 .collect();
1854 let mut headers: Vec<(String, String)> =
1855 mock.headers.into_iter().collect();
1856 headers.sort_by(|a, b| a.0.cmp(&b.0));
1857 routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
1858 }
1859 }
1860 }
1861 }
1862}
1863
1864// ---------------------------------------------------------------------------
1865// Entry point
1866// ---------------------------------------------------------------------------
1867
1868#[tokio::main]
1869async fn main() {
1870 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1871 let fixtures_dir = Path::new(&fixtures_dir_arg);
1872
1873 let routes = load_routes(fixtures_dir);
1874 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1875
1876 let route_table: RouteTable = Arc::new(routes);
1877 let app = Router::new().fallback(handle_request).with_state(route_table);
1878
1879 let listener = TcpListener::bind("127.0.0.1:0")
1880 .await
1881 .expect("mock-server: failed to bind port");
1882 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1883
1884 // Print the URL so the parent process can read it.
1885 println!("MOCK_SERVER_URL=http://{addr}");
1886 // Flush stdout explicitly so the parent does not block waiting.
1887 use std::io::Write;
1888 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1889
1890 // Spawn the server in the background.
1891 tokio::spawn(async move {
1892 axum::serve(listener, app).await.expect("mock-server: server error");
1893 });
1894
1895 // Block until stdin is closed — the parent process controls lifetime.
1896 let stdin = io::stdin();
1897 let mut lines = stdin.lock().lines();
1898 while lines.next().is_some() {}
1899}
1900"#
1901}
1902
1903#[allow(clippy::too_many_arguments)]
1908fn render_assertion(
1909 out: &mut String,
1910 assertion: &Assertion,
1911 result_var: &str,
1912 module: &str,
1913 dep_name: &str,
1914 is_error_context: bool,
1915 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
1917 result_is_tree: bool,
1918 result_is_simple: bool,
1919 result_is_vec: bool,
1920 result_is_option: bool,
1921) {
1922 let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
1927 if result_is_vec && has_field && !is_error_context {
1928 let _ = writeln!(out, " for r in &{result_var} {{");
1929 render_assertion(
1930 out,
1931 assertion,
1932 "r",
1933 module,
1934 dep_name,
1935 is_error_context,
1936 unwrapped_fields,
1937 field_resolver,
1938 result_is_tree,
1939 result_is_simple,
1940 false, result_is_option,
1942 );
1943 let _ = writeln!(out, " }}");
1944 return;
1945 }
1946 if result_is_option && !is_error_context {
1949 let assertion_type = assertion.assertion_type.as_str();
1950 if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
1951 let check = if assertion_type == "is_empty" {
1952 "is_none"
1953 } else {
1954 "is_some"
1955 };
1956 let _ = writeln!(
1957 out,
1958 " assert!({result_var}.{check}(), \"expected Option to be {check}\");"
1959 );
1960 return;
1961 }
1962 let _ = writeln!(
1966 out,
1967 " let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
1968 );
1969 render_assertion(
1970 out,
1971 assertion,
1972 "r",
1973 module,
1974 dep_name,
1975 is_error_context,
1976 unwrapped_fields,
1977 field_resolver,
1978 result_is_tree,
1979 result_is_simple,
1980 result_is_vec,
1981 false, );
1983 return;
1984 }
1985 let _ = dep_name;
1986 if let Some(f) = &assertion.field {
1990 match f.as_str() {
1991 "chunks_have_content" => {
1992 match assertion.assertion_type.as_str() {
1993 "is_true" => {
1994 let _ = writeln!(
1995 out,
1996 " assert!({result_var}.chunks.as_ref().is_some_and(|chunks| !chunks.is_empty() && chunks.iter().all(|c| !c.content.is_empty())), \"expected all chunks to have content\");"
1997 );
1998 }
1999 "is_false" => {
2000 let _ = writeln!(
2001 out,
2002 " assert!({result_var}.chunks.as_ref().is_none() || {result_var}.chunks.as_ref().unwrap().iter().any(|c| c.content.is_empty()), \"expected some chunks to be empty\");"
2003 );
2004 }
2005 _ => {
2006 let _ = writeln!(
2007 out,
2008 " // unsupported assertion type on synthetic field chunks_have_content"
2009 );
2010 }
2011 }
2012 return;
2013 }
2014 "chunks_have_embeddings" => {
2015 match assertion.assertion_type.as_str() {
2016 "is_true" => {
2017 let _ = writeln!(
2018 out,
2019 " assert!({result_var}.chunks.as_ref().is_some_and(|c| c.iter().all(|ch| ch.embedding.as_ref().is_some_and(|e| !e.is_empty()))), \"expected all chunks to have embeddings\");"
2020 );
2021 }
2022 "is_false" => {
2023 let _ = writeln!(
2024 out,
2025 " assert!({result_var}.chunks.as_ref().is_none_or(|c| c.iter().any(|ch| ch.embedding.as_ref().is_none_or(|e| e.is_empty()))), \"expected some chunks to lack embeddings\");"
2026 );
2027 }
2028 _ => {
2029 let _ = writeln!(
2030 out,
2031 " // unsupported assertion type on synthetic field chunks_have_embeddings"
2032 );
2033 }
2034 }
2035 return;
2036 }
2037 "embeddings" => {
2041 let embed_list = result_var.to_string();
2044 match assertion.assertion_type.as_str() {
2045 "count_equals" => {
2046 if let Some(val) = &assertion.value {
2047 if let Some(n) = val.as_u64() {
2048 let _ = writeln!(
2049 out,
2050 " assert_eq!({embed_list}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {embed_list}.len());"
2051 );
2052 }
2053 }
2054 }
2055 "count_min" => {
2056 if let Some(val) = &assertion.value {
2057 if let Some(n) = val.as_u64() {
2058 if n <= 1 {
2059 let _ =
2060 writeln!(out, " assert!(!{embed_list}.is_empty(), \"expected >= {n}\");");
2061 } else {
2062 let _ = writeln!(
2063 out,
2064 " assert!({embed_list}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {embed_list}.len());"
2065 );
2066 }
2067 }
2068 }
2069 }
2070 "not_empty" => {
2071 let _ = writeln!(
2072 out,
2073 " assert!(!{embed_list}.is_empty(), \"expected non-empty embeddings\");"
2074 );
2075 }
2076 "is_empty" => {
2077 let _ = writeln!(
2078 out,
2079 " assert!({embed_list}.is_empty(), \"expected empty embeddings\");"
2080 );
2081 }
2082 _ => {
2083 let _ = writeln!(
2084 out,
2085 " // skipped: unsupported assertion type on synthetic field 'embeddings'"
2086 );
2087 }
2088 }
2089 return;
2090 }
2091 "embedding_dimensions" => {
2092 let embed_list = result_var;
2093 let expr = format!("{embed_list}.first().map_or(0, |e| e.len())");
2094 match assertion.assertion_type.as_str() {
2095 "equals" => {
2096 if let Some(val) = &assertion.value {
2097 let lit = numeric_literal(val);
2098 let _ = writeln!(
2099 out,
2100 " assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2101 );
2102 }
2103 }
2104 "greater_than" => {
2105 if let Some(val) = &assertion.value {
2106 let lit = numeric_literal(val);
2107 let _ = writeln!(out, " assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2108 }
2109 }
2110 _ => {
2111 let _ = writeln!(
2112 out,
2113 " // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
2114 );
2115 }
2116 }
2117 return;
2118 }
2119 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
2120 let embed_list = result_var;
2121 let pred = match f.as_str() {
2122 "embeddings_valid" => {
2123 format!("{embed_list}.iter().all(|e| !e.is_empty())")
2124 }
2125 "embeddings_finite" => {
2126 format!("{embed_list}.iter().all(|e| e.iter().all(|v| v.is_finite()))")
2127 }
2128 "embeddings_non_zero" => {
2129 format!("{embed_list}.iter().all(|e| e.iter().any(|v| *v != 0.0_f32))")
2130 }
2131 "embeddings_normalized" => {
2132 format!(
2133 "{embed_list}.iter().all(|e| {{ let n: f64 = e.iter().map(|v| f64::from(*v) * f64::from(*v)).sum(); (n - 1.0_f64).abs() < 1e-3 }})"
2134 )
2135 }
2136 _ => unreachable!(),
2137 };
2138 match assertion.assertion_type.as_str() {
2139 "is_true" => {
2140 let _ = writeln!(out, " assert!({pred}, \"expected true\");");
2141 }
2142 "is_false" => {
2143 let _ = writeln!(out, " assert!(!({pred}), \"expected false\");");
2144 }
2145 _ => {
2146 let _ = writeln!(
2147 out,
2148 " // skipped: unsupported assertion type on synthetic field '{f}'"
2149 );
2150 }
2151 }
2152 return;
2153 }
2154 "keywords" => {
2158 let accessor = format!("{result_var}.extracted_keywords");
2159 match assertion.assertion_type.as_str() {
2160 "not_empty" => {
2161 let _ = writeln!(
2162 out,
2163 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected keywords to be present and non-empty\");"
2164 );
2165 }
2166 "is_empty" => {
2167 let _ = writeln!(
2168 out,
2169 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected keywords to be empty or absent\");"
2170 );
2171 }
2172 "count_min" => {
2173 if let Some(val) = &assertion.value {
2174 if let Some(n) = val.as_u64() {
2175 if n <= 1 {
2176 let _ = writeln!(
2177 out,
2178 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2179 );
2180 } else {
2181 let _ = writeln!(
2182 out,
2183 " assert!({accessor}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} keywords\");"
2184 );
2185 }
2186 }
2187 }
2188 }
2189 "count_equals" => {
2190 if let Some(val) = &assertion.value {
2191 if let Some(n) = val.as_u64() {
2192 let _ = writeln!(
2193 out,
2194 " assert!({accessor}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} keywords\");"
2195 );
2196 }
2197 }
2198 }
2199 _ => {
2200 let _ = writeln!(
2201 out,
2202 " // skipped: unsupported assertion type on synthetic field 'keywords'"
2203 );
2204 }
2205 }
2206 return;
2207 }
2208 "keywords_count" => {
2209 let expr = format!("{result_var}.extracted_keywords.as_ref().map_or(0, |v| v.len())");
2210 match assertion.assertion_type.as_str() {
2211 "equals" => {
2212 if let Some(val) = &assertion.value {
2213 let lit = numeric_literal(val);
2214 let _ = writeln!(
2215 out,
2216 " assert_eq!({expr}, {lit} as usize, \"equals assertion failed\");"
2217 );
2218 }
2219 }
2220 "less_than_or_equal" => {
2221 if let Some(val) = &assertion.value {
2222 let lit = numeric_literal(val);
2223 let _ = writeln!(out, " assert!({expr} <= {lit} as usize, \"expected <= {lit}\");");
2224 }
2225 }
2226 "greater_than_or_equal" => {
2227 if let Some(val) = &assertion.value {
2228 let lit = numeric_literal(val);
2229 let _ = writeln!(out, " assert!({expr} >= {lit} as usize, \"expected >= {lit}\");");
2230 }
2231 }
2232 "greater_than" => {
2233 if let Some(val) = &assertion.value {
2234 let lit = numeric_literal(val);
2235 let _ = writeln!(out, " assert!({expr} > {lit} as usize, \"expected > {lit}\");");
2236 }
2237 }
2238 "less_than" => {
2239 if let Some(val) = &assertion.value {
2240 let lit = numeric_literal(val);
2241 let _ = writeln!(out, " assert!({expr} < {lit} as usize, \"expected < {lit}\");");
2242 }
2243 }
2244 _ => {
2245 let _ = writeln!(
2246 out,
2247 " // skipped: unsupported assertion type on synthetic field 'keywords_count'"
2248 );
2249 }
2250 }
2251 return;
2252 }
2253 _ => {}
2254 }
2255 }
2256
2257 if let Some(f) = &assertion.field {
2259 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
2260 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
2261 return;
2262 }
2263 }
2264
2265 let field_access = match &assertion.field {
2273 Some(f) if !f.is_empty() => {
2274 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
2275 local_var.clone()
2276 } else if result_is_simple {
2277 result_var.to_string()
2280 } else if f == result_var {
2281 result_var.to_string()
2284 } else if result_is_tree {
2285 tree_field_access_expr(f, result_var, module)
2288 } else {
2289 field_resolver.accessor(f, "rust", result_var)
2290 }
2291 }
2292 _ => result_var.to_string(),
2293 };
2294
2295 let is_unwrapped = assertion
2297 .field
2298 .as_ref()
2299 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
2300
2301 match assertion.assertion_type.as_str() {
2302 "error" => {
2303 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
2304 if let Some(serde_json::Value::String(msg)) = &assertion.value {
2305 let escaped = escape_rust(msg);
2306 let _ = writeln!(
2307 out,
2308 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
2309 );
2310 }
2311 }
2312 "not_error" => {
2313 }
2315 "equals" => {
2316 if let Some(val) = &assertion.value {
2317 let expected = value_to_rust_string(val);
2318 if is_error_context {
2319 return;
2320 }
2321 if val.is_string() {
2324 let is_opt_str_not_unwrapped = assertion.field.as_ref().is_some_and(|f| {
2329 let resolved = field_resolver.resolve(f);
2330 let is_opt = field_resolver.is_optional(resolved);
2331 let is_arr = field_resolver.is_array(resolved);
2332 is_opt && !is_arr && !is_unwrapped
2333 });
2334 let field_expr = if is_opt_str_not_unwrapped {
2335 format!("{field_access}.as_deref().unwrap_or(\"\").trim()")
2336 } else {
2337 format!("{field_access}.trim()")
2338 };
2339 let _ = writeln!(
2340 out,
2341 " assert_eq!({field_expr}, {expected}, \"equals assertion failed\");"
2342 );
2343 } else if val.is_boolean() {
2344 if val.as_bool() == Some(true) {
2346 let _ = writeln!(out, " assert!({field_access}, \"equals assertion failed\");");
2347 } else {
2348 let _ = writeln!(out, " assert!(!{field_access}, \"equals assertion failed\");");
2349 }
2350 } else {
2351 let is_opt = assertion.field.as_ref().is_some_and(|f| {
2353 let resolved = field_resolver.resolve(f);
2354 field_resolver.is_optional(resolved)
2355 });
2356 if is_opt
2357 && !unwrapped_fields
2358 .iter()
2359 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
2360 {
2361 let _ = writeln!(
2362 out,
2363 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
2364 );
2365 } else {
2366 let _ = writeln!(
2367 out,
2368 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
2369 );
2370 }
2371 }
2372 }
2373 }
2374 "contains" => {
2375 if let Some(val) = &assertion.value {
2376 let expected = value_to_rust_string(val);
2377 let line = format!(
2378 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2379 );
2380 let _ = writeln!(out, "{line}");
2381 }
2382 }
2383 "contains_all" => {
2384 if let Some(values) = &assertion.values {
2385 for val in values {
2386 let expected = value_to_rust_string(val);
2387 let line = format!(
2388 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
2389 );
2390 let _ = writeln!(out, "{line}");
2391 }
2392 }
2393 }
2394 "not_contains" => {
2395 if let Some(val) = &assertion.value {
2396 let expected = value_to_rust_string(val);
2397 let line = format!(
2398 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
2399 );
2400 let _ = writeln!(out, "{line}");
2401 }
2402 }
2403 "not_empty" => {
2404 if let Some(f) = &assertion.field {
2405 let resolved = field_resolver.resolve(f);
2406 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2407 let is_arr = field_resolver.is_array(resolved);
2408 if is_opt && is_arr {
2409 let accessor = field_resolver.accessor(f, "rust", result_var);
2411 let _ = writeln!(
2412 out,
2413 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected {f} to be present and non-empty\");"
2414 );
2415 } else if is_opt {
2416 let accessor = field_resolver.accessor(f, "rust", result_var);
2418 let _ = writeln!(
2419 out,
2420 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
2421 );
2422 } else {
2423 let _ = writeln!(
2424 out,
2425 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2426 );
2427 }
2428 } else if result_is_option {
2429 let _ = writeln!(
2431 out,
2432 " assert!({field_access}.is_some(), \"expected non-empty value\");"
2433 );
2434 } else {
2435 let _ = writeln!(
2437 out,
2438 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
2439 );
2440 }
2441 }
2442 "is_empty" => {
2443 if let Some(f) = &assertion.field {
2444 let resolved = field_resolver.resolve(f);
2445 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2446 let is_arr = field_resolver.is_array(resolved);
2447 if is_opt && is_arr {
2448 let accessor = field_resolver.accessor(f, "rust", result_var);
2450 let _ = writeln!(
2451 out,
2452 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected {f} to be empty or absent\");"
2453 );
2454 } else if is_opt {
2455 let accessor = field_resolver.accessor(f, "rust", result_var);
2456 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
2457 } else {
2458 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
2459 }
2460 } else {
2461 let _ = writeln!(out, " assert!({field_access}.is_none(), \"expected empty value\");");
2462 }
2463 }
2464 "contains_any" => {
2465 if let Some(values) = &assertion.values {
2466 let checks: Vec<String> = values
2467 .iter()
2468 .map(|v| {
2469 let expected = value_to_rust_string(v);
2470 format!("{field_access}.contains({expected})")
2471 })
2472 .collect();
2473 let joined = checks.join(" || ");
2474 let _ = writeln!(
2475 out,
2476 " assert!({joined}, \"expected to contain at least one of the specified values\");"
2477 );
2478 }
2479 }
2480 "greater_than" => {
2481 if let Some(val) = &assertion.value {
2482 if val.as_f64().is_some_and(|n| n < 0.0) {
2484 let _ = writeln!(
2485 out,
2486 " // skipped: greater_than with negative value is always true for unsigned types"
2487 );
2488 } else if val.as_u64() == Some(0) {
2489 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2491 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected > 0\");");
2492 } else {
2493 let lit = numeric_literal(val);
2494 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
2495 }
2496 }
2497 }
2498 "less_than" => {
2499 if let Some(val) = &assertion.value {
2500 let lit = numeric_literal(val);
2501 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
2502 }
2503 }
2504 "greater_than_or_equal" => {
2505 if let Some(val) = &assertion.value {
2506 let lit = numeric_literal(val);
2507 let is_opt_numeric = assertion.field.as_ref().is_some_and(|f| {
2510 let resolved = field_resolver.resolve(f);
2511 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2512 let is_arr = field_resolver.is_array(resolved);
2513 is_opt && !is_arr
2514 });
2515 if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
2516 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2520 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= 1\");");
2521 } else if is_opt_numeric {
2522 let _ = writeln!(
2524 out,
2525 " assert!({field_access}.unwrap_or(0) >= {lit}, \"expected >= {lit}\");"
2526 );
2527 } else {
2528 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
2529 }
2530 }
2531 }
2532 "less_than_or_equal" => {
2533 if let Some(val) = &assertion.value {
2534 let lit = numeric_literal(val);
2535 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
2536 }
2537 }
2538 "starts_with" => {
2539 if let Some(val) = &assertion.value {
2540 let expected = value_to_rust_string(val);
2541 let _ = writeln!(
2542 out,
2543 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
2544 );
2545 }
2546 }
2547 "ends_with" => {
2548 if let Some(val) = &assertion.value {
2549 let expected = value_to_rust_string(val);
2550 let _ = writeln!(
2551 out,
2552 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
2553 );
2554 }
2555 }
2556 "min_length" => {
2557 if let Some(val) = &assertion.value {
2558 if let Some(n) = val.as_u64() {
2559 let _ = writeln!(
2560 out,
2561 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
2562 );
2563 }
2564 }
2565 }
2566 "max_length" => {
2567 if let Some(val) = &assertion.value {
2568 if let Some(n) = val.as_u64() {
2569 let _ = writeln!(
2570 out,
2571 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
2572 );
2573 }
2574 }
2575 }
2576 "count_min" => {
2577 if let Some(val) = &assertion.value {
2578 if let Some(n) = val.as_u64() {
2579 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2580 let resolved = field_resolver.resolve(f);
2581 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2582 let is_arr = field_resolver.is_array(resolved);
2583 is_opt && is_arr
2584 });
2585 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2586 if opt_arr_field {
2587 if n <= 1 {
2589 let _ = writeln!(
2590 out,
2591 " assert!({base}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
2592 );
2593 } else {
2594 let _ = writeln!(
2595 out,
2596 " assert!({base}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} elements\");"
2597 );
2598 }
2599 } else if n <= 1 {
2600 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= {n}\");");
2601 } else {
2602 let _ = writeln!(
2603 out,
2604 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
2605 );
2606 }
2607 }
2608 }
2609 }
2610 "count_equals" => {
2611 if let Some(val) = &assertion.value {
2612 if let Some(n) = val.as_u64() {
2613 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
2614 let resolved = field_resolver.resolve(f);
2615 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
2616 let is_arr = field_resolver.is_array(resolved);
2617 is_opt && is_arr
2618 });
2619 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
2620 if opt_arr_field {
2621 let _ = writeln!(
2622 out,
2623 " assert!({base}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} elements\");"
2624 );
2625 } else {
2626 let _ = writeln!(
2627 out,
2628 " assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
2629 );
2630 }
2631 }
2632 }
2633 }
2634 "is_true" => {
2635 let _ = writeln!(out, " assert!({field_access}, \"expected true\");");
2636 }
2637 "is_false" => {
2638 let _ = writeln!(out, " assert!(!{field_access}, \"expected false\");");
2639 }
2640 "method_result" => {
2641 if let Some(method_name) = &assertion.method {
2642 let call_expr = if result_is_tree {
2646 build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
2647 } else if let Some(args) = &assertion.args {
2648 let arg_lit = json_to_rust_literal(args, "");
2649 format!("{field_access}.{method_name}({arg_lit})")
2650 } else {
2651 format!("{field_access}.{method_name}()")
2652 };
2653
2654 let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
2657
2658 let check = assertion.check.as_deref().unwrap_or("is_true");
2659 match check {
2660 "equals" => {
2661 if let Some(val) = &assertion.value {
2662 if val.is_boolean() {
2663 if val.as_bool() == Some(true) {
2664 let _ = writeln!(
2665 out,
2666 " assert!({call_expr}, \"method_result equals assertion failed\");"
2667 );
2668 } else {
2669 let _ = writeln!(
2670 out,
2671 " assert!(!{call_expr}, \"method_result equals assertion failed\");"
2672 );
2673 }
2674 } else {
2675 let expected = value_to_rust_string(val);
2676 let _ = writeln!(
2677 out,
2678 " assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
2679 );
2680 }
2681 }
2682 }
2683 "is_true" => {
2684 let _ = writeln!(
2685 out,
2686 " assert!({call_expr}, \"method_result is_true assertion failed\");"
2687 );
2688 }
2689 "is_false" => {
2690 let _ = writeln!(
2691 out,
2692 " assert!(!{call_expr}, \"method_result is_false assertion failed\");"
2693 );
2694 }
2695 "greater_than_or_equal" => {
2696 if let Some(val) = &assertion.value {
2697 let lit = numeric_literal(val);
2698 if returns_numeric {
2699 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2701 } else if val.as_u64() == Some(1) {
2702 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
2704 } else {
2705 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
2706 }
2707 }
2708 }
2709 "count_min" => {
2710 if let Some(val) = &assertion.value {
2711 let n = val.as_u64().unwrap_or(0);
2712 if n <= 1 {
2713 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
2714 } else {
2715 let _ = writeln!(
2716 out,
2717 " assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
2718 );
2719 }
2720 }
2721 }
2722 "is_error" => {
2723 let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
2725 let _ = writeln!(
2726 out,
2727 " assert!({raw_call}.is_err(), \"expected method to return error\");"
2728 );
2729 }
2730 "contains" => {
2731 if let Some(val) = &assertion.value {
2732 let expected = value_to_rust_string(val);
2733 let _ = writeln!(
2734 out,
2735 " assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
2736 );
2737 }
2738 }
2739 "not_empty" => {
2740 let _ = writeln!(
2741 out,
2742 " assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
2743 );
2744 }
2745 "is_empty" => {
2746 let _ = writeln!(out, " assert!({call_expr}.is_empty(), \"expected empty result\");");
2747 }
2748 other_check => {
2749 panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
2750 }
2751 }
2752 } else {
2753 panic!("Rust e2e generator: method_result assertion missing 'method' field");
2754 }
2755 }
2756 other => {
2757 panic!("Rust e2e generator: unsupported assertion type: {other}");
2758 }
2759 }
2760}
2761
2762fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
2770 match field {
2771 "root_child_count" => format!("{result_var}.root_node().child_count()"),
2772 "root_node_type" => format!("{result_var}.root_node().kind()"),
2773 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
2774 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
2775 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
2776 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
2777 other => format!("{result_var}.{other}"),
2780 }
2781}
2782
2783fn build_tree_call_expr(
2790 field_access: &str,
2791 method_name: &str,
2792 args: Option<&serde_json::Value>,
2793 module: &str,
2794) -> String {
2795 match method_name {
2796 "root_child_count" => format!("{field_access}.root_node().child_count()"),
2797 "root_node_type" => format!("{field_access}.root_node().kind()"),
2798 "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
2799 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
2800 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
2801 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
2802 "contains_node_type" => {
2803 let node_type = args
2804 .and_then(|a| a.get("node_type"))
2805 .and_then(|v| v.as_str())
2806 .unwrap_or("");
2807 format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
2808 }
2809 "find_nodes_by_type" => {
2810 let node_type = args
2811 .and_then(|a| a.get("node_type"))
2812 .and_then(|v| v.as_str())
2813 .unwrap_or("");
2814 format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
2815 }
2816 "run_query" => {
2817 let query_source = args
2818 .and_then(|a| a.get("query_source"))
2819 .and_then(|v| v.as_str())
2820 .unwrap_or("");
2821 let language = args
2822 .and_then(|a| a.get("language"))
2823 .and_then(|v| v.as_str())
2824 .unwrap_or("");
2825 format!(
2828 "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
2829 )
2830 }
2831 _ => {
2833 if let Some(args) = args {
2834 let arg_lit = json_to_rust_literal(args, "");
2835 format!("{field_access}.{method_name}({arg_lit})")
2836 } else {
2837 format!("{field_access}.{method_name}()")
2838 }
2839 }
2840 }
2841}
2842
2843fn is_tree_numeric_method(method_name: &str) -> bool {
2847 matches!(
2848 method_name,
2849 "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
2850 )
2851}
2852
2853fn numeric_literal(value: &serde_json::Value) -> String {
2859 if let Some(n) = value.as_f64() {
2860 if n.fract() == 0.0 {
2861 return format!("{}", n as i64);
2864 }
2865 return format!("{n}_f64");
2866 }
2867 value.to_string()
2869}
2870
2871fn value_to_rust_string(value: &serde_json::Value) -> String {
2872 match value {
2873 serde_json::Value::String(s) => rust_raw_string(s),
2874 serde_json::Value::Bool(b) => format!("{b}"),
2875 serde_json::Value::Number(n) => n.to_string(),
2876 other => {
2877 let s = other.to_string();
2878 format!("\"{s}\"")
2879 }
2880 }
2881}
2882
2883fn resolve_visitor_trait(module: &str) -> String {
2889 if module.contains("html_to_markdown") {
2891 "HtmlVisitor".to_string()
2892 } else {
2893 "Visitor".to_string()
2895 }
2896}
2897
2898fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
2906 let params = match method_name {
2910 "visit_link" => "_: &NodeContext, _: &str, _: &str, _: &str",
2911 "visit_image" => "_: &NodeContext, _: &str, _: &str, _: &str",
2912 "visit_heading" => "_: &NodeContext, _: u8, _: &str, _: Option<&str>",
2913 "visit_code_block" => "_: &NodeContext, _: Option<&str>, _: &str",
2914 "visit_code_inline"
2915 | "visit_strong"
2916 | "visit_emphasis"
2917 | "visit_strikethrough"
2918 | "visit_underline"
2919 | "visit_subscript"
2920 | "visit_superscript"
2921 | "visit_mark"
2922 | "visit_button"
2923 | "visit_summary"
2924 | "visit_figcaption"
2925 | "visit_definition_term"
2926 | "visit_definition_description" => "_: &NodeContext, _: &str",
2927 "visit_text" => "_: &NodeContext, _: &str",
2928 "visit_list_item" => "_: &NodeContext, _: bool, _: &str, _: &str",
2929 "visit_blockquote" => "_: &NodeContext, _: &str, _: u32",
2930 "visit_table_row" => "_: &NodeContext, _: &[String], _: bool",
2931 "visit_custom_element" => "_: &NodeContext, _: &str, _: &str",
2932 "visit_form" => "_: &NodeContext, _: &str, _: &str",
2933 "visit_input" => "_: &NodeContext, _: &str, _: &str, _: &str",
2934 "visit_audio" | "visit_video" | "visit_iframe" => "_: &NodeContext, _: &str",
2935 "visit_details" => "_: &NodeContext, _: bool",
2936 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2937 "_: &NodeContext, _: &str"
2938 }
2939 "visit_list_start" => "_: &NodeContext, _: bool",
2940 "visit_list_end" => "_: &NodeContext, _: bool, _: &str",
2941 _ => "_: &NodeContext",
2942 };
2943
2944 let _ = writeln!(out, " fn {method_name}(&mut self, {params}) -> VisitResult {{");
2945 match action {
2946 CallbackAction::Skip => {
2947 let _ = writeln!(out, " VisitResult::Skip");
2948 }
2949 CallbackAction::Continue => {
2950 let _ = writeln!(out, " VisitResult::Continue");
2951 }
2952 CallbackAction::PreserveHtml => {
2953 let _ = writeln!(out, " VisitResult::PreserveHtml");
2954 }
2955 CallbackAction::Custom { output } => {
2956 let escaped = escape_rust(output);
2957 let _ = writeln!(out, " VisitResult::Custom(\"{escaped}\".to_string())");
2958 }
2959 CallbackAction::CustomTemplate { template } => {
2960 let escaped = escape_rust(template);
2961 let _ = writeln!(out, " VisitResult::Custom(format!(\"{escaped}\"))");
2962 }
2963 }
2964 let _ = writeln!(out, " }}");
2965}