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, Fixture, FixtureGroup};
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.needs_mock_server());
50
51 let any_async_call = std::iter::once(&e2e_config.call)
53 .chain(e2e_config.calls.values())
54 .any(|c| c.r#async);
55 let needs_tokio = needs_mock_server || any_async_call;
56
57 let crate_version = resolve_crate_version(e2e_config);
58 files.push(GeneratedFile {
59 path: output_base.join("Cargo.toml"),
60 content: render_cargo_toml(
61 &crate_name,
62 &dep_name,
63 &crate_path,
64 needs_serde_json,
65 needs_mock_server,
66 needs_tokio,
67 e2e_config.dep_mode,
68 crate_version.as_deref(),
69 &alef_config.crate_config.features,
70 ),
71 generated_header: true,
72 });
73
74 if needs_mock_server {
76 files.push(GeneratedFile {
77 path: output_base.join("tests").join("mock_server.rs"),
78 content: render_mock_server_module(),
79 generated_header: true,
80 });
81 files.push(GeneratedFile {
83 path: output_base.join("src").join("main.rs"),
84 content: render_mock_server_binary(),
85 generated_header: true,
86 });
87 }
88
89 for group in groups {
91 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
92
93 if fixtures.is_empty() {
94 continue;
95 }
96
97 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
98 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
99
100 files.push(GeneratedFile {
101 path: output_base.join("tests").join(filename),
102 content,
103 generated_header: true,
104 });
105 }
106
107 Ok(files)
108 }
109
110 fn language_name(&self) -> &'static str {
111 "rust"
112 }
113}
114
115fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
120 alef_config.crate_config.name.clone()
124}
125
126fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
127 e2e_config
128 .resolve_package("rust")
129 .and_then(|p| p.path.clone())
130 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
131}
132
133fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
134 e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
135}
136
137fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
138 call_config
139 .overrides
140 .get("rust")
141 .and_then(|o| o.function.clone())
142 .unwrap_or_else(|| call_config.function.clone())
143}
144
145fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
146 resolve_module_for_call(&e2e_config.call, dep_name)
147}
148
149fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
150 let overrides = call_config.overrides.get("rust");
153 overrides
154 .and_then(|o| o.crate_name.clone())
155 .or_else(|| overrides.and_then(|o| o.module.clone()))
156 .unwrap_or_else(|| dep_name.to_string())
157}
158
159fn is_skipped(fixture: &Fixture, language: &str) -> bool {
160 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
161}
162
163#[allow(clippy::too_many_arguments)]
168pub fn render_cargo_toml(
169 crate_name: &str,
170 dep_name: &str,
171 crate_path: &str,
172 needs_serde_json: bool,
173 needs_mock_server: bool,
174 needs_tokio: bool,
175 dep_mode: crate::config::DependencyMode,
176 version: Option<&str>,
177 features: &[String],
178) -> String {
179 let e2e_name = format!("{dep_name}-e2e-rust");
180 let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
184 let features_str = if effective_features.is_empty() {
185 String::new()
186 } else {
187 format!(", default-features = false, features = {:?}", effective_features)
188 };
189 let dep_spec = match dep_mode {
190 crate::config::DependencyMode::Registry => {
191 let ver = version.unwrap_or("0.1.0");
192 if crate_name != dep_name {
193 format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
194 } else if effective_features.is_empty() {
195 format!("{dep_name} = \"{ver}\"")
196 } else {
197 format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
198 }
199 }
200 crate::config::DependencyMode::Local => {
201 if crate_name != dep_name {
202 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
203 } else if effective_features.is_empty() {
204 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
205 } else {
206 format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
207 }
208 }
209 };
210 let effective_needs_serde_json = needs_serde_json || needs_mock_server;
213 let serde_line = if effective_needs_serde_json {
214 "\nserde_json = \"1\""
215 } else {
216 ""
217 };
218 let mock_lines = if needs_mock_server {
226 format!(
227 "\naxum = \"{axum}\"\ntokio-stream = \"{tokio_stream}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
228 axum = tv::cargo::AXUM,
229 tokio_stream = tv::cargo::TOKIO_STREAM,
230 walkdir = tv::cargo::WALKDIR,
231 )
232 } else {
233 String::new()
234 };
235 let mut machete_ignored: Vec<&str> = Vec::new();
236 if effective_needs_serde_json {
237 machete_ignored.push("\"serde_json\"");
238 }
239 if needs_mock_server {
240 machete_ignored.push("\"axum\"");
241 machete_ignored.push("\"tokio-stream\"");
242 machete_ignored.push("\"serde\"");
243 machete_ignored.push("\"walkdir\"");
244 }
245 let machete_section = if machete_ignored.is_empty() {
246 String::new()
247 } else {
248 format!(
249 "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
250 machete_ignored.join(", ")
251 )
252 };
253 let tokio_line = if needs_tokio {
254 "\ntokio = { version = \"1\", features = [\"full\"] }"
255 } else {
256 ""
257 };
258 let bin_section = if needs_mock_server {
259 "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
260 } else {
261 ""
262 };
263 let header = hash::header(CommentStyle::Hash);
264 format!(
265 r#"{header}
266[workspace]
267
268[package]
269name = "{e2e_name}"
270version = "0.1.0"
271edition = "2021"
272license = "MIT"
273publish = false
274{bin_section}
275[dependencies]
276{dep_spec}{serde_line}{mock_lines}{tokio_line}
277{machete_section}"#
278 )
279}
280
281fn render_test_file(
282 category: &str,
283 fixtures: &[&Fixture],
284 e2e_config: &E2eConfig,
285 dep_name: &str,
286 needs_mock_server: bool,
287) -> String {
288 let mut out = String::new();
289 out.push_str(&hash::header(CommentStyle::DoubleSlash));
290 let _ = writeln!(out, "//! E2e tests for category: {category}");
291 let _ = writeln!(out);
292
293 let module = resolve_module(e2e_config, dep_name);
294 let field_resolver = FieldResolver::new(
295 &e2e_config.fields,
296 &e2e_config.fields_optional,
297 &e2e_config.result_fields,
298 &e2e_config.fields_array,
299 );
300
301 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
305 for fixture in fixtures.iter() {
306 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
307 let fn_name = resolve_function_name_for_call(call_config);
308 let mod_name = resolve_module_for_call(call_config, dep_name);
309 imported.insert((mod_name, fn_name));
310 }
311 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
313 for (mod_name, fn_name) in &imported {
314 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
315 }
316 for (mod_name, fns) in &by_module {
317 if fns.len() == 1 {
318 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
319 } else {
320 let joined = fns.join(", ");
321 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
322 }
323 }
324
325 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
327 if has_handle_args {
328 let _ = writeln!(out, "use {module}::CrawlConfig;");
329 }
330 for arg in &e2e_config.call.args {
331 if arg.arg_type == "handle" {
332 use heck::ToSnakeCase;
333 let constructor_name = format!("create_{}", arg.name.to_snake_case());
334 let _ = writeln!(out, "use {module}::{constructor_name};");
335 }
336 }
337
338 let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.needs_mock_server());
340 if file_needs_mock {
341 let _ = writeln!(out, "mod mock_server;");
342 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
343 }
344
345 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
349 if file_needs_visitor {
350 let visitor_trait = resolve_visitor_trait(&module);
351 let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
352 }
353
354 let _ = writeln!(out);
355
356 for fixture in fixtures {
357 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
358 let _ = writeln!(out);
359 }
360
361 if !out.ends_with('\n') {
362 out.push('\n');
363 }
364 out
365}
366
367fn render_test_function(
368 out: &mut String,
369 fixture: &Fixture,
370 e2e_config: &E2eConfig,
371 dep_name: &str,
372 field_resolver: &FieldResolver,
373) {
374 let fn_name = sanitize_ident(&fixture.id);
375 let description = &fixture.description;
376 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
377 let function_name = resolve_function_name_for_call(call_config);
378 let module = resolve_module_for_call(call_config, dep_name);
379 let result_var = &call_config.result_var;
380 let has_mock = fixture.needs_mock_server();
381
382 let is_async = call_config.r#async || has_mock;
384 if is_async {
385 let _ = writeln!(out, "#[tokio::test]");
386 let _ = writeln!(out, "async fn test_{fn_name}() {{");
387 } else {
388 let _ = writeln!(out, "#[test]");
389 let _ = writeln!(out, "fn test_{fn_name}() {{");
390 }
391 let _ = writeln!(out, " // {description}");
392
393 if has_mock {
396 render_mock_server_setup(out, fixture, e2e_config);
397 }
398
399 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
401
402 let rust_overrides = call_config.overrides.get("rust");
404 let wrap_options_in_some = rust_overrides.is_some_and(|o| o.wrap_options_in_some);
405 let extra_args: Vec<String> = rust_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
406
407 let mut arg_exprs: Vec<String> = Vec::new();
409 for arg in &call_config.args {
410 let value = resolve_field(&fixture.input, &arg.field);
411 let var_name = &arg.name;
412 let (bindings, expr) = render_rust_arg(
413 var_name,
414 value,
415 &arg.arg_type,
416 arg.optional,
417 &module,
418 &fixture.id,
419 if has_mock {
420 Some("mock_server.url.as_str()")
421 } else {
422 None
423 },
424 arg.owned,
425 arg.element_type.as_deref(),
426 );
427 for binding in &bindings {
428 let _ = writeln!(out, " {binding}");
429 }
430 let final_expr = if wrap_options_in_some && arg.arg_type == "json_object" {
434 if let Some(rest) = expr.strip_prefix('&') {
435 format!("Some({rest}.clone())")
436 } else {
437 format!("Some({expr})")
438 }
439 } else {
440 expr
441 };
442 arg_exprs.push(final_expr);
443 }
444
445 if let Some(visitor_spec) = &fixture.visitor {
447 let _ = writeln!(out, " struct _TestVisitor;");
448 let _ = writeln!(out, " impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
449 for (method_name, action) in &visitor_spec.callbacks {
450 emit_rust_visitor_method(out, method_name, action);
451 }
452 let _ = writeln!(out, " }}");
453 let _ = writeln!(
454 out,
455 " let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
456 );
457 arg_exprs.push("Some(visitor)".to_string());
458 } else {
459 arg_exprs.extend(extra_args);
462 }
463
464 let args_str = arg_exprs.join(", ");
465
466 let await_suffix = if is_async { ".await" } else { "" };
467
468 let result_is_tree = call_config.result_var == "tree";
469 let result_is_simple = rust_overrides.is_some_and(|o| o.result_is_simple);
472 let result_is_vec = rust_overrides.is_some_and(|o| o.result_is_vec);
475 let result_is_option = rust_overrides.is_some_and(|o| o.result_is_option);
478
479 if has_error_assertion {
480 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
481 for assertion in &fixture.assertions {
483 render_assertion(
484 out,
485 assertion,
486 result_var,
487 &module,
488 dep_name,
489 true,
490 &[],
491 field_resolver,
492 result_is_tree,
493 result_is_simple,
494 false,
495 false,
496 );
497 }
498 let _ = writeln!(out, "}}");
499 return;
500 }
501
502 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
504
505 let has_usable_assertion = fixture.assertions.iter().any(|a| {
509 if a.assertion_type == "not_error" || a.assertion_type == "error" {
510 return false;
511 }
512 if a.assertion_type == "method_result" {
513 let supported_checks = [
516 "equals",
517 "is_true",
518 "is_false",
519 "greater_than_or_equal",
520 "count_min",
521 "is_error",
522 "contains",
523 "not_empty",
524 "is_empty",
525 ];
526 let check = a.check.as_deref().unwrap_or("is_true");
527 if a.method.is_none() || !supported_checks.contains(&check) {
528 return false;
529 }
530 }
531 match &a.field {
532 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
533 _ => true,
534 }
535 });
536
537 let result_binding = if has_usable_assertion {
538 result_var.to_string()
539 } else {
540 "_".to_string()
541 };
542
543 let has_field_access = fixture
547 .assertions
548 .iter()
549 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
550 let only_emptiness_checks = !has_field_access
551 && fixture.assertions.iter().all(|a| {
552 matches!(
553 a.assertion_type.as_str(),
554 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
555 )
556 });
557
558 let returns_result = rust_overrides
561 .and_then(|o| o.returns_result)
562 .unwrap_or(call_config.returns_result);
563
564 let unwrap_suffix = if returns_result {
565 ".expect(\"should succeed\")"
566 } else {
567 ""
568 };
569 if only_emptiness_checks || !returns_result {
570 let _ = writeln!(
572 out,
573 " let {result_binding} = {function_name}({args_str}){await_suffix};"
574 );
575 } else if has_not_error || !fixture.assertions.is_empty() {
576 let _ = writeln!(
577 out,
578 " let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
579 );
580 } else {
581 let _ = writeln!(
582 out,
583 " let {result_binding} = {function_name}({args_str}){await_suffix};"
584 );
585 }
586
587 let string_assertion_types = [
593 "equals",
594 "contains",
595 "contains_all",
596 "contains_any",
597 "not_contains",
598 "starts_with",
599 "ends_with",
600 "min_length",
601 "max_length",
602 "matches_regex",
603 ];
604 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); if !result_is_vec {
606 for assertion in &fixture.assertions {
607 if let Some(f) = &assertion.field {
608 if !f.is_empty()
609 && string_assertion_types.contains(&assertion.assertion_type.as_str())
610 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
611 {
612 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
615 if !is_string_assertion {
616 continue;
617 }
618 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
619 let _ = writeln!(out, " {binding}");
620 unwrapped_fields.push((f.clone(), local_var));
621 }
622 }
623 }
624 }
625 }
626
627 for assertion in &fixture.assertions {
629 if assertion.assertion_type == "not_error" {
630 continue;
632 }
633 render_assertion(
634 out,
635 assertion,
636 result_var,
637 &module,
638 dep_name,
639 false,
640 &unwrapped_fields,
641 field_resolver,
642 result_is_tree,
643 result_is_simple,
644 result_is_vec,
645 result_is_option,
646 );
647 }
648
649 let _ = writeln!(out, "}}");
650}
651
652#[allow(clippy::too_many_arguments)]
657fn render_rust_arg(
658 name: &str,
659 value: &serde_json::Value,
660 arg_type: &str,
661 optional: bool,
662 module: &str,
663 fixture_id: &str,
664 mock_base_url: Option<&str>,
665 owned: bool,
666 element_type: Option<&str>,
667) -> (Vec<String>, String) {
668 if arg_type == "mock_url" {
669 let lines = vec![format!(
670 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
671 )];
672 return (lines, format!("&{name}"));
673 }
674 if arg_type == "base_url" {
676 if let Some(url_expr) = mock_base_url {
677 return (vec![], url_expr.to_string());
678 }
679 }
681 if arg_type == "handle" {
682 use heck::ToSnakeCase;
686 let constructor_name = format!("create_{}", name.to_snake_case());
687 let mut lines = Vec::new();
688 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
689 lines.push(format!(
690 "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
691 ));
692 } else {
693 let json_literal = serde_json::to_string(value).unwrap_or_default();
695 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
696 lines.push(format!(
697 "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
698 ));
699 lines.push(format!(
700 "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
701 ));
702 }
703 return (lines, format!("&{name}"));
704 }
705 if arg_type == "json_object" {
706 return render_json_object_arg(name, value, optional, owned, element_type, module);
707 }
708 if value.is_null() && !optional {
709 let default_val = match arg_type {
711 "string" => "String::new()".to_string(),
712 "int" | "integer" => "0".to_string(),
713 "float" | "number" => "0.0_f64".to_string(),
714 "bool" | "boolean" => "false".to_string(),
715 _ => "Default::default()".to_string(),
716 };
717 let expr = if arg_type == "string" {
719 format!("&{name}")
720 } else {
721 name.to_string()
722 };
723 return (vec![format!("let {name} = {default_val};")], expr);
724 }
725 let literal = json_to_rust_literal(value, arg_type);
726 let pass_by_ref = arg_type == "bytes";
729 let optional_expr = |n: &str| {
730 if arg_type == "string" {
731 format!("{n}.as_deref()")
732 } else if arg_type == "bytes" {
733 format!("{n}.as_deref().map(|v| v.as_slice())")
734 } else {
735 n.to_string()
739 }
740 };
741 let expr = |n: &str| {
742 if arg_type == "bytes" {
743 format!("{n}.as_bytes()")
744 } else if pass_by_ref {
745 format!("&{n}")
746 } else {
747 n.to_string()
748 }
749 };
750 if optional && value.is_null() {
751 let none_decl = match arg_type {
752 "string" => format!("let {name}: Option<String> = None;"),
753 "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
754 _ => format!("let {name} = None;"),
755 };
756 (vec![none_decl], optional_expr(name))
757 } else if optional {
758 (vec![format!("let {name} = Some({literal});")], optional_expr(name))
759 } else {
760 (vec![format!("let {name} = {literal};")], expr(name))
761 }
762}
763
764fn render_json_object_arg(
772 name: &str,
773 value: &serde_json::Value,
774 optional: bool,
775 owned: bool,
776 element_type: Option<&str>,
777 _module: &str,
778) -> (Vec<String>, String) {
779 let pass_by_ref = !owned;
781
782 if value.is_null() && optional {
783 let expr = if pass_by_ref {
785 format!("&{name}")
786 } else {
787 name.to_string()
788 };
789 return (vec![format!("let {name} = Default::default();")], expr);
790 }
791
792 let normalized = super::normalize_json_keys_to_snake_case(value);
795 let json_literal = json_value_to_macro_literal(&normalized);
797 let mut lines = Vec::new();
798 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
799
800 let deser_expr = if let Some(elem) = element_type {
803 format!("serde_json::from_value::<Vec<{elem}>>({name}_json).unwrap()")
804 } else {
805 format!("serde_json::from_value({name}_json).unwrap()")
806 };
807
808 lines.push(format!("let {name} = {deser_expr};"));
811 let expr = if pass_by_ref {
812 format!("&{name}")
813 } else {
814 name.to_string()
815 };
816 (lines, expr)
817}
818
819fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
821 match value {
822 serde_json::Value::Null => "null".to_string(),
823 serde_json::Value::Bool(b) => format!("{b}"),
824 serde_json::Value::Number(n) => n.to_string(),
825 serde_json::Value::String(s) => {
826 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
827 format!("\"{escaped}\"")
828 }
829 serde_json::Value::Array(arr) => {
830 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
831 format!("[{}]", items.join(", "))
832 }
833 serde_json::Value::Object(obj) => {
834 let entries: Vec<String> = obj
835 .iter()
836 .map(|(k, v)| {
837 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
838 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
839 })
840 .collect();
841 format!("{{{}}}", entries.join(", "))
842 }
843 }
844}
845
846fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
847 match value {
848 serde_json::Value::Null => "None".to_string(),
849 serde_json::Value::Bool(b) => format!("{b}"),
850 serde_json::Value::Number(n) => {
851 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
852 if let Some(f) = n.as_f64() {
853 return format!("{f}_f64");
854 }
855 }
856 n.to_string()
857 }
858 serde_json::Value::String(s) => rust_raw_string(s),
859 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
860 let json_str = serde_json::to_string(value).unwrap_or_default();
861 let literal = rust_raw_string(&json_str);
862 format!("serde_json::from_str({literal}).unwrap()")
863 }
864 }
865}
866
867fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
877 let mock = match fixture.mock_response.as_ref() {
878 Some(m) => m,
879 None => return,
880 };
881
882 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
884 let path = call_config.path.as_deref().unwrap_or("/");
885 let method = call_config.method.as_deref().unwrap_or("POST");
886
887 let status = mock.status;
888
889 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
891 header_entries.sort_by(|a, b| a.0.cmp(b.0));
892 let render_headers = |out: &mut String| {
893 let _ = writeln!(out, " headers: vec![");
894 for (name, value) in &header_entries {
895 let n = rust_raw_string(name);
896 let v = rust_raw_string(value);
897 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
898 }
899 let _ = writeln!(out, " ],");
900 };
901
902 if let Some(chunks) = &mock.stream_chunks {
903 let _ = writeln!(out, " let mock_route = MockRoute {{");
905 let _ = writeln!(out, " path: \"{path}\",");
906 let _ = writeln!(out, " method: \"{method}\",");
907 let _ = writeln!(out, " status: {status},");
908 let _ = writeln!(out, " body: String::new(),");
909 let _ = writeln!(out, " stream_chunks: vec![");
910 for chunk in chunks {
911 let chunk_str = match chunk {
912 serde_json::Value::String(s) => rust_raw_string(s),
913 other => {
914 let s = serde_json::to_string(other).unwrap_or_default();
915 rust_raw_string(&s)
916 }
917 };
918 let _ = writeln!(out, " {chunk_str}.to_string(),");
919 }
920 let _ = writeln!(out, " ],");
921 render_headers(out);
922 let _ = writeln!(out, " }};");
923 } else {
924 let body_str = match &mock.body {
926 Some(b) => {
927 let s = serde_json::to_string(b).unwrap_or_default();
928 rust_raw_string(&s)
929 }
930 None => rust_raw_string("{}"),
931 };
932 let _ = writeln!(out, " let mock_route = MockRoute {{");
933 let _ = writeln!(out, " path: \"{path}\",");
934 let _ = writeln!(out, " method: \"{method}\",");
935 let _ = writeln!(out, " status: {status},");
936 let _ = writeln!(out, " body: {body_str}.to_string(),");
937 let _ = writeln!(out, " stream_chunks: vec![],");
938 render_headers(out);
939 let _ = writeln!(out, " }};");
940 }
941
942 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
943}
944
945pub fn render_mock_server_module() -> String {
947 hash::header(CommentStyle::DoubleSlash)
950 + r#"//
951// Minimal axum-based mock HTTP server for e2e tests.
952
953use std::net::SocketAddr;
954use std::sync::Arc;
955
956use axum::Router;
957use axum::body::Body;
958use axum::extract::State;
959use axum::http::{Request, StatusCode};
960use axum::response::{IntoResponse, Response};
961use tokio::net::TcpListener;
962
963/// A single mock route: match by path + method, return a configured response.
964#[derive(Clone, Debug)]
965pub struct MockRoute {
966 /// URL path to match, e.g. `"/v1/chat/completions"`.
967 pub path: &'static str,
968 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
969 pub method: &'static str,
970 /// HTTP status code to return.
971 pub status: u16,
972 /// Response body JSON string (used when `stream_chunks` is empty).
973 pub body: String,
974 /// Ordered SSE data payloads for streaming responses.
975 /// Each entry becomes `data: <chunk>\n\n` in the response.
976 /// A final `data: [DONE]\n\n` is always appended.
977 pub stream_chunks: Vec<String>,
978 /// Response headers to apply (name, value) pairs.
979 /// Multiple entries with the same name produce multiple header lines.
980 pub headers: Vec<(String, String)>,
981}
982
983struct ServerState {
984 routes: Vec<MockRoute>,
985}
986
987pub struct MockServer {
988 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
989 pub url: String,
990 handle: tokio::task::JoinHandle<()>,
991}
992
993impl MockServer {
994 /// Start a mock server with the given routes. Binds to a random port on
995 /// localhost and returns immediately once the server is listening.
996 pub async fn start(routes: Vec<MockRoute>) -> Self {
997 let state = Arc::new(ServerState { routes });
998
999 let app = Router::new().fallback(handle_request).with_state(state);
1000
1001 let listener = TcpListener::bind("127.0.0.1:0")
1002 .await
1003 .expect("Failed to bind mock server port");
1004 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
1005 let url = format!("http://{addr}");
1006
1007 let handle = tokio::spawn(async move {
1008 axum::serve(listener, app).await.expect("Mock server failed");
1009 });
1010
1011 MockServer { url, handle }
1012 }
1013
1014 /// Stop the mock server.
1015 pub fn shutdown(self) {
1016 self.handle.abort();
1017 }
1018}
1019
1020impl Drop for MockServer {
1021 fn drop(&mut self) {
1022 self.handle.abort();
1023 }
1024}
1025
1026async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
1027 let path = req.uri().path().to_owned();
1028 let method = req.method().as_str().to_uppercase();
1029
1030 for route in &state.routes {
1031 if route.path == path && route.method.to_uppercase() == method {
1032 let status =
1033 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1034
1035 if !route.stream_chunks.is_empty() {
1036 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
1037 let mut sse = String::new();
1038 for chunk in &route.stream_chunks {
1039 sse.push_str("data: ");
1040 sse.push_str(chunk);
1041 sse.push_str("\n\n");
1042 }
1043 sse.push_str("data: [DONE]\n\n");
1044
1045 let mut builder = Response::builder()
1046 .status(status)
1047 .header("content-type", "text/event-stream")
1048 .header("cache-control", "no-cache");
1049 for (name, value) in &route.headers {
1050 builder = builder.header(name, value);
1051 }
1052 return builder.body(Body::from(sse)).unwrap().into_response();
1053 }
1054
1055 let mut builder =
1056 Response::builder().status(status).header("content-type", "application/json");
1057 for (name, value) in &route.headers {
1058 builder = builder.header(name, value);
1059 }
1060 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
1061 }
1062 }
1063
1064 // No matching route → 404.
1065 Response::builder()
1066 .status(StatusCode::NOT_FOUND)
1067 .body(Body::from(format!("No mock route for {method} {path}")))
1068 .unwrap()
1069 .into_response()
1070}
1071"#
1072}
1073
1074pub fn render_mock_server_binary() -> String {
1086 hash::header(CommentStyle::DoubleSlash)
1087 + r#"//
1088// Standalone mock HTTP server binary for cross-language e2e tests.
1089// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
1090//
1091// Usage: mock-server [fixtures-dir]
1092// fixtures-dir defaults to "../../fixtures"
1093//
1094// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
1095// then blocks until stdin is closed (parent process exit triggers cleanup).
1096
1097use std::collections::HashMap;
1098use std::io::{self, BufRead};
1099use std::net::SocketAddr;
1100use std::path::Path;
1101use std::sync::Arc;
1102
1103use axum::Router;
1104use axum::body::Body;
1105use axum::extract::State;
1106use axum::http::{Request, StatusCode};
1107use axum::response::{IntoResponse, Response};
1108use serde::Deserialize;
1109use tokio::net::TcpListener;
1110
1111// ---------------------------------------------------------------------------
1112// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1113// Supports both schemas:
1114// liter-llm: mock_response: { status, body, stream_chunks }
1115// spikard: http.expected_response: { status_code, body, headers }
1116// ---------------------------------------------------------------------------
1117
1118#[derive(Debug, Deserialize)]
1119struct MockResponse {
1120 status: u16,
1121 #[serde(default)]
1122 body: Option<serde_json::Value>,
1123 #[serde(default)]
1124 stream_chunks: Option<Vec<serde_json::Value>>,
1125 #[serde(default)]
1126 headers: HashMap<String, String>,
1127}
1128
1129#[derive(Debug, Deserialize)]
1130struct HttpExpectedResponse {
1131 status_code: u16,
1132 #[serde(default)]
1133 body: Option<serde_json::Value>,
1134 #[serde(default)]
1135 headers: HashMap<String, String>,
1136}
1137
1138#[derive(Debug, Deserialize)]
1139struct HttpFixture {
1140 expected_response: HttpExpectedResponse,
1141}
1142
1143#[derive(Debug, Deserialize)]
1144struct Fixture {
1145 id: String,
1146 #[serde(default)]
1147 mock_response: Option<MockResponse>,
1148 #[serde(default)]
1149 http: Option<HttpFixture>,
1150}
1151
1152impl Fixture {
1153 /// Bridge both schemas into a unified MockResponse.
1154 fn as_mock_response(&self) -> Option<MockResponse> {
1155 if let Some(mock) = &self.mock_response {
1156 return Some(MockResponse {
1157 status: mock.status,
1158 body: mock.body.clone(),
1159 stream_chunks: mock.stream_chunks.clone(),
1160 headers: mock.headers.clone(),
1161 });
1162 }
1163 if let Some(http) = &self.http {
1164 return Some(MockResponse {
1165 status: http.expected_response.status_code,
1166 body: http.expected_response.body.clone(),
1167 stream_chunks: None,
1168 headers: http.expected_response.headers.clone(),
1169 });
1170 }
1171 None
1172 }
1173}
1174
1175// ---------------------------------------------------------------------------
1176// Route table
1177// ---------------------------------------------------------------------------
1178
1179#[derive(Clone, Debug)]
1180struct MockRoute {
1181 status: u16,
1182 body: String,
1183 stream_chunks: Vec<String>,
1184 headers: Vec<(String, String)>,
1185}
1186
1187type RouteTable = Arc<HashMap<String, MockRoute>>;
1188
1189// ---------------------------------------------------------------------------
1190// Axum handler
1191// ---------------------------------------------------------------------------
1192
1193async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1194 let path = req.uri().path().to_owned();
1195
1196 // Try exact match first
1197 if let Some(route) = routes.get(&path) {
1198 return serve_route(route);
1199 }
1200
1201 // Try prefix match: find a route that is a prefix of the request path
1202 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1203 for (route_path, route) in routes.iter() {
1204 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1205 return serve_route(route);
1206 }
1207 }
1208
1209 Response::builder()
1210 .status(StatusCode::NOT_FOUND)
1211 .body(Body::from(format!("No mock route for {path}")))
1212 .unwrap()
1213 .into_response()
1214}
1215
1216fn serve_route(route: &MockRoute) -> Response {
1217 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1218
1219 if !route.stream_chunks.is_empty() {
1220 let mut sse = String::new();
1221 for chunk in &route.stream_chunks {
1222 sse.push_str("data: ");
1223 sse.push_str(chunk);
1224 sse.push_str("\n\n");
1225 }
1226 sse.push_str("data: [DONE]\n\n");
1227
1228 let mut builder = Response::builder()
1229 .status(status)
1230 .header("content-type", "text/event-stream")
1231 .header("cache-control", "no-cache");
1232 for (name, value) in &route.headers {
1233 builder = builder.header(name, value);
1234 }
1235 return builder.body(Body::from(sse)).unwrap().into_response();
1236 }
1237
1238 let mut builder = Response::builder().status(status).header("content-type", "application/json");
1239 for (name, value) in &route.headers {
1240 builder = builder.header(name, value);
1241 }
1242 builder.body(Body::from(route.body.clone())).unwrap().into_response()
1243}
1244
1245// ---------------------------------------------------------------------------
1246// Fixture loading
1247// ---------------------------------------------------------------------------
1248
1249fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1250 let mut routes = HashMap::new();
1251 load_routes_recursive(fixtures_dir, &mut routes);
1252 routes
1253}
1254
1255fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1256 let entries = match std::fs::read_dir(dir) {
1257 Ok(e) => e,
1258 Err(err) => {
1259 eprintln!("warning: cannot read directory {}: {err}", dir.display());
1260 return;
1261 }
1262 };
1263
1264 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1265 paths.sort();
1266
1267 for path in paths {
1268 if path.is_dir() {
1269 load_routes_recursive(&path, routes);
1270 } else if path.extension().is_some_and(|ext| ext == "json") {
1271 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1272 if filename == "schema.json" || filename.starts_with('_') {
1273 continue;
1274 }
1275 let content = match std::fs::read_to_string(&path) {
1276 Ok(c) => c,
1277 Err(err) => {
1278 eprintln!("warning: cannot read {}: {err}", path.display());
1279 continue;
1280 }
1281 };
1282 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1283 match serde_json::from_str(&content) {
1284 Ok(v) => v,
1285 Err(err) => {
1286 eprintln!("warning: cannot parse {}: {err}", path.display());
1287 continue;
1288 }
1289 }
1290 } else {
1291 match serde_json::from_str::<Fixture>(&content) {
1292 Ok(f) => vec![f],
1293 Err(err) => {
1294 eprintln!("warning: cannot parse {}: {err}", path.display());
1295 continue;
1296 }
1297 }
1298 };
1299
1300 for fixture in fixtures {
1301 if let Some(mock) = fixture.as_mock_response() {
1302 let route_path = format!("/fixtures/{}", fixture.id);
1303 let body = mock
1304 .body
1305 .as_ref()
1306 .map(|b| serde_json::to_string(b).unwrap_or_default())
1307 .unwrap_or_default();
1308 let stream_chunks = mock
1309 .stream_chunks
1310 .unwrap_or_default()
1311 .into_iter()
1312 .map(|c| match c {
1313 serde_json::Value::String(s) => s,
1314 other => serde_json::to_string(&other).unwrap_or_default(),
1315 })
1316 .collect();
1317 let mut headers: Vec<(String, String)> =
1318 mock.headers.into_iter().collect();
1319 headers.sort_by(|a, b| a.0.cmp(&b.0));
1320 routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
1321 }
1322 }
1323 }
1324 }
1325}
1326
1327// ---------------------------------------------------------------------------
1328// Entry point
1329// ---------------------------------------------------------------------------
1330
1331#[tokio::main]
1332async fn main() {
1333 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1334 let fixtures_dir = Path::new(&fixtures_dir_arg);
1335
1336 let routes = load_routes(fixtures_dir);
1337 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1338
1339 let route_table: RouteTable = Arc::new(routes);
1340 let app = Router::new().fallback(handle_request).with_state(route_table);
1341
1342 let listener = TcpListener::bind("127.0.0.1:0")
1343 .await
1344 .expect("mock-server: failed to bind port");
1345 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1346
1347 // Print the URL so the parent process can read it.
1348 println!("MOCK_SERVER_URL=http://{addr}");
1349 // Flush stdout explicitly so the parent does not block waiting.
1350 use std::io::Write;
1351 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1352
1353 // Spawn the server in the background.
1354 tokio::spawn(async move {
1355 axum::serve(listener, app).await.expect("mock-server: server error");
1356 });
1357
1358 // Block until stdin is closed — the parent process controls lifetime.
1359 let stdin = io::stdin();
1360 let mut lines = stdin.lock().lines();
1361 while lines.next().is_some() {}
1362}
1363"#
1364}
1365
1366#[allow(clippy::too_many_arguments)]
1371fn render_assertion(
1372 out: &mut String,
1373 assertion: &Assertion,
1374 result_var: &str,
1375 module: &str,
1376 dep_name: &str,
1377 is_error_context: bool,
1378 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
1380 result_is_tree: bool,
1381 result_is_simple: bool,
1382 result_is_vec: bool,
1383 result_is_option: bool,
1384) {
1385 let has_field = assertion.field.as_ref().is_some_and(|f| !f.is_empty());
1390 if result_is_vec && has_field && !is_error_context {
1391 let _ = writeln!(out, " for r in &{result_var} {{");
1392 render_assertion(
1393 out,
1394 assertion,
1395 "r",
1396 module,
1397 dep_name,
1398 is_error_context,
1399 unwrapped_fields,
1400 field_resolver,
1401 result_is_tree,
1402 result_is_simple,
1403 false, result_is_option,
1405 );
1406 let _ = writeln!(out, " }}");
1407 return;
1408 }
1409 if result_is_option && !is_error_context {
1412 let assertion_type = assertion.assertion_type.as_str();
1413 if !has_field && (assertion_type == "is_empty" || assertion_type == "not_empty") {
1414 let check = if assertion_type == "is_empty" {
1415 "is_none"
1416 } else {
1417 "is_some"
1418 };
1419 let _ = writeln!(
1420 out,
1421 " assert!({result_var}.{check}(), \"expected Option to be {check}\");"
1422 );
1423 return;
1424 }
1425 let _ = writeln!(
1429 out,
1430 " let r = {result_var}.as_ref().expect(\"Option<T> should be Some\");"
1431 );
1432 render_assertion(
1433 out,
1434 assertion,
1435 "r",
1436 module,
1437 dep_name,
1438 is_error_context,
1439 unwrapped_fields,
1440 field_resolver,
1441 result_is_tree,
1442 result_is_simple,
1443 result_is_vec,
1444 false, );
1446 return;
1447 }
1448 let _ = dep_name;
1449 if let Some(f) = &assertion.field {
1451 if f == "chunks_have_content" {
1452 match assertion.assertion_type.as_str() {
1453 "is_true" => {
1454 let _ = writeln!(
1455 out,
1456 " 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\");"
1457 );
1458 }
1459 "is_false" => {
1460 let _ = writeln!(
1461 out,
1462 " 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\");"
1463 );
1464 }
1465 _ => {
1466 let _ = writeln!(
1467 out,
1468 " // unsupported assertion type on synthetic field chunks_have_content"
1469 );
1470 }
1471 }
1472 return;
1473 }
1474 }
1475
1476 if let Some(f) = &assertion.field {
1478 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1479 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1480 return;
1481 }
1482 }
1483
1484 let field_access = match &assertion.field {
1492 Some(f) if !f.is_empty() => {
1493 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
1494 local_var.clone()
1495 } else if result_is_simple {
1496 result_var.to_string()
1499 } else if f == result_var {
1500 result_var.to_string()
1503 } else if result_is_tree {
1504 tree_field_access_expr(f, result_var, module)
1507 } else {
1508 field_resolver.accessor(f, "rust", result_var)
1509 }
1510 }
1511 _ => result_var.to_string(),
1512 };
1513
1514 let is_unwrapped = assertion
1516 .field
1517 .as_ref()
1518 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
1519
1520 match assertion.assertion_type.as_str() {
1521 "error" => {
1522 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
1523 if let Some(serde_json::Value::String(msg)) = &assertion.value {
1524 let escaped = escape_rust(msg);
1525 let _ = writeln!(
1526 out,
1527 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
1528 );
1529 }
1530 }
1531 "not_error" => {
1532 }
1534 "equals" => {
1535 if let Some(val) = &assertion.value {
1536 let expected = value_to_rust_string(val);
1537 if is_error_context {
1538 return;
1539 }
1540 if val.is_string() {
1543 let is_opt_str_not_unwrapped = assertion.field.as_ref().is_some_and(|f| {
1548 let resolved = field_resolver.resolve(f);
1549 let is_opt = field_resolver.is_optional(resolved);
1550 let is_arr = field_resolver.is_array(resolved);
1551 is_opt && !is_arr && !is_unwrapped
1552 });
1553 let field_expr = if is_opt_str_not_unwrapped {
1554 format!("{field_access}.as_deref().unwrap_or(\"\").trim()")
1555 } else {
1556 format!("{field_access}.trim()")
1557 };
1558 let _ = writeln!(
1559 out,
1560 " assert_eq!({field_expr}, {expected}, \"equals assertion failed\");"
1561 );
1562 } else if val.is_boolean() {
1563 if val.as_bool() == Some(true) {
1565 let _ = writeln!(out, " assert!({field_access}, \"equals assertion failed\");");
1566 } else {
1567 let _ = writeln!(out, " assert!(!{field_access}, \"equals assertion failed\");");
1568 }
1569 } else {
1570 let is_opt = assertion.field.as_ref().is_some_and(|f| {
1572 let resolved = field_resolver.resolve(f);
1573 field_resolver.is_optional(resolved)
1574 });
1575 if is_opt
1576 && !unwrapped_fields
1577 .iter()
1578 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
1579 {
1580 let _ = writeln!(
1581 out,
1582 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
1583 );
1584 } else {
1585 let _ = writeln!(
1586 out,
1587 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
1588 );
1589 }
1590 }
1591 }
1592 }
1593 "contains" => {
1594 if let Some(val) = &assertion.value {
1595 let expected = value_to_rust_string(val);
1596 let line = format!(
1597 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1598 );
1599 let _ = writeln!(out, "{line}");
1600 }
1601 }
1602 "contains_all" => {
1603 if let Some(values) = &assertion.values {
1604 for val in values {
1605 let expected = value_to_rust_string(val);
1606 let line = format!(
1607 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1608 );
1609 let _ = writeln!(out, "{line}");
1610 }
1611 }
1612 }
1613 "not_contains" => {
1614 if let Some(val) = &assertion.value {
1615 let expected = value_to_rust_string(val);
1616 let line = format!(
1617 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
1618 );
1619 let _ = writeln!(out, "{line}");
1620 }
1621 }
1622 "not_empty" => {
1623 if let Some(f) = &assertion.field {
1624 let resolved = field_resolver.resolve(f);
1625 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
1626 let is_arr = field_resolver.is_array(resolved);
1627 if is_opt && is_arr {
1628 let accessor = field_resolver.accessor(f, "rust", result_var);
1630 let _ = writeln!(
1631 out,
1632 " assert!({accessor}.as_ref().is_some_and(|v| !v.is_empty()), \"expected {f} to be present and non-empty\");"
1633 );
1634 } else if is_opt {
1635 let accessor = field_resolver.accessor(f, "rust", result_var);
1637 let _ = writeln!(
1638 out,
1639 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
1640 );
1641 } else {
1642 let _ = writeln!(
1643 out,
1644 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
1645 );
1646 }
1647 } else if result_is_option {
1648 let _ = writeln!(
1650 out,
1651 " assert!({field_access}.is_some(), \"expected non-empty value\");"
1652 );
1653 } else {
1654 let _ = writeln!(
1656 out,
1657 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
1658 );
1659 }
1660 }
1661 "is_empty" => {
1662 if let Some(f) = &assertion.field {
1663 let resolved = field_resolver.resolve(f);
1664 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
1665 let is_arr = field_resolver.is_array(resolved);
1666 if is_opt && is_arr {
1667 let accessor = field_resolver.accessor(f, "rust", result_var);
1669 let _ = writeln!(
1670 out,
1671 " assert!({accessor}.as_ref().is_none_or(|v| v.is_empty()), \"expected {f} to be empty or absent\");"
1672 );
1673 } else if is_opt {
1674 let accessor = field_resolver.accessor(f, "rust", result_var);
1675 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
1676 } else {
1677 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
1678 }
1679 } else {
1680 let _ = writeln!(out, " assert!({field_access}.is_none(), \"expected empty value\");");
1681 }
1682 }
1683 "contains_any" => {
1684 if let Some(values) = &assertion.values {
1685 let checks: Vec<String> = values
1686 .iter()
1687 .map(|v| {
1688 let expected = value_to_rust_string(v);
1689 format!("{field_access}.contains({expected})")
1690 })
1691 .collect();
1692 let joined = checks.join(" || ");
1693 let _ = writeln!(
1694 out,
1695 " assert!({joined}, \"expected to contain at least one of the specified values\");"
1696 );
1697 }
1698 }
1699 "greater_than" => {
1700 if let Some(val) = &assertion.value {
1701 if val.as_f64().is_some_and(|n| n < 0.0) {
1703 let _ = writeln!(
1704 out,
1705 " // skipped: greater_than with negative value is always true for unsigned types"
1706 );
1707 } else if val.as_u64() == Some(0) {
1708 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1710 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected > 0\");");
1711 } else {
1712 let lit = numeric_literal(val);
1713 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
1714 }
1715 }
1716 }
1717 "less_than" => {
1718 if let Some(val) = &assertion.value {
1719 let lit = numeric_literal(val);
1720 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
1721 }
1722 }
1723 "greater_than_or_equal" => {
1724 if let Some(val) = &assertion.value {
1725 let lit = numeric_literal(val);
1726 let is_opt_numeric = assertion.field.as_ref().is_some_and(|f| {
1729 let resolved = field_resolver.resolve(f);
1730 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
1731 let is_arr = field_resolver.is_array(resolved);
1732 is_opt && !is_arr
1733 });
1734 if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
1735 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1739 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= 1\");");
1740 } else if is_opt_numeric {
1741 let _ = writeln!(
1743 out,
1744 " assert!({field_access}.unwrap_or(0) >= {lit}, \"expected >= {lit}\");"
1745 );
1746 } else {
1747 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
1748 }
1749 }
1750 }
1751 "less_than_or_equal" => {
1752 if let Some(val) = &assertion.value {
1753 let lit = numeric_literal(val);
1754 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
1755 }
1756 }
1757 "starts_with" => {
1758 if let Some(val) = &assertion.value {
1759 let expected = value_to_rust_string(val);
1760 let _ = writeln!(
1761 out,
1762 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
1763 );
1764 }
1765 }
1766 "ends_with" => {
1767 if let Some(val) = &assertion.value {
1768 let expected = value_to_rust_string(val);
1769 let _ = writeln!(
1770 out,
1771 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
1772 );
1773 }
1774 }
1775 "min_length" => {
1776 if let Some(val) = &assertion.value {
1777 if let Some(n) = val.as_u64() {
1778 let _ = writeln!(
1779 out,
1780 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
1781 );
1782 }
1783 }
1784 }
1785 "max_length" => {
1786 if let Some(val) = &assertion.value {
1787 if let Some(n) = val.as_u64() {
1788 let _ = writeln!(
1789 out,
1790 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
1791 );
1792 }
1793 }
1794 }
1795 "count_min" => {
1796 if let Some(val) = &assertion.value {
1797 if let Some(n) = val.as_u64() {
1798 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
1799 let resolved = field_resolver.resolve(f);
1800 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
1801 let is_arr = field_resolver.is_array(resolved);
1802 is_opt && is_arr
1803 });
1804 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1805 if opt_arr_field {
1806 if n <= 1 {
1808 let _ = writeln!(
1809 out,
1810 " assert!({base}.as_ref().is_some_and(|v| !v.is_empty()), \"expected >= {n}\");"
1811 );
1812 } else {
1813 let _ = writeln!(
1814 out,
1815 " assert!({base}.as_ref().is_some_and(|v| v.len() >= {n}), \"expected at least {n} elements\");"
1816 );
1817 }
1818 } else if n <= 1 {
1819 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= {n}\");");
1820 } else {
1821 let _ = writeln!(
1822 out,
1823 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
1824 );
1825 }
1826 }
1827 }
1828 }
1829 "count_equals" => {
1830 if let Some(val) = &assertion.value {
1831 if let Some(n) = val.as_u64() {
1832 let opt_arr_field = assertion.field.as_ref().is_some_and(|f| {
1833 let resolved = field_resolver.resolve(f);
1834 let is_opt = !is_unwrapped && field_resolver.is_optional(resolved);
1835 let is_arr = field_resolver.is_array(resolved);
1836 is_opt && is_arr
1837 });
1838 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1839 if opt_arr_field {
1840 let _ = writeln!(
1841 out,
1842 " assert!({base}.as_ref().is_some_and(|v| v.len() == {n}), \"expected exactly {n} elements\");"
1843 );
1844 } else {
1845 let _ = writeln!(
1846 out,
1847 " assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
1848 );
1849 }
1850 }
1851 }
1852 }
1853 "is_true" => {
1854 let _ = writeln!(out, " assert!({field_access}, \"expected true\");");
1855 }
1856 "is_false" => {
1857 let _ = writeln!(out, " assert!(!{field_access}, \"expected false\");");
1858 }
1859 "method_result" => {
1860 if let Some(method_name) = &assertion.method {
1861 let call_expr = if result_is_tree {
1865 build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
1866 } else if let Some(args) = &assertion.args {
1867 let arg_lit = json_to_rust_literal(args, "");
1868 format!("{field_access}.{method_name}({arg_lit})")
1869 } else {
1870 format!("{field_access}.{method_name}()")
1871 };
1872
1873 let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
1876
1877 let check = assertion.check.as_deref().unwrap_or("is_true");
1878 match check {
1879 "equals" => {
1880 if let Some(val) = &assertion.value {
1881 if val.is_boolean() {
1882 if val.as_bool() == Some(true) {
1883 let _ = writeln!(
1884 out,
1885 " assert!({call_expr}, \"method_result equals assertion failed\");"
1886 );
1887 } else {
1888 let _ = writeln!(
1889 out,
1890 " assert!(!{call_expr}, \"method_result equals assertion failed\");"
1891 );
1892 }
1893 } else {
1894 let expected = value_to_rust_string(val);
1895 let _ = writeln!(
1896 out,
1897 " assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
1898 );
1899 }
1900 }
1901 }
1902 "is_true" => {
1903 let _ = writeln!(
1904 out,
1905 " assert!({call_expr}, \"method_result is_true assertion failed\");"
1906 );
1907 }
1908 "is_false" => {
1909 let _ = writeln!(
1910 out,
1911 " assert!(!{call_expr}, \"method_result is_false assertion failed\");"
1912 );
1913 }
1914 "greater_than_or_equal" => {
1915 if let Some(val) = &assertion.value {
1916 let lit = numeric_literal(val);
1917 if returns_numeric {
1918 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1920 } else if val.as_u64() == Some(1) {
1921 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
1923 } else {
1924 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1925 }
1926 }
1927 }
1928 "count_min" => {
1929 if let Some(val) = &assertion.value {
1930 let n = val.as_u64().unwrap_or(0);
1931 if n <= 1 {
1932 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
1933 } else {
1934 let _ = writeln!(
1935 out,
1936 " assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
1937 );
1938 }
1939 }
1940 }
1941 "is_error" => {
1942 let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
1944 let _ = writeln!(
1945 out,
1946 " assert!({raw_call}.is_err(), \"expected method to return error\");"
1947 );
1948 }
1949 "contains" => {
1950 if let Some(val) = &assertion.value {
1951 let expected = value_to_rust_string(val);
1952 let _ = writeln!(
1953 out,
1954 " assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
1955 );
1956 }
1957 }
1958 "not_empty" => {
1959 let _ = writeln!(
1960 out,
1961 " assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
1962 );
1963 }
1964 "is_empty" => {
1965 let _ = writeln!(out, " assert!({call_expr}.is_empty(), \"expected empty result\");");
1966 }
1967 other_check => {
1968 panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
1969 }
1970 }
1971 } else {
1972 panic!("Rust e2e generator: method_result assertion missing 'method' field");
1973 }
1974 }
1975 other => {
1976 panic!("Rust e2e generator: unsupported assertion type: {other}");
1977 }
1978 }
1979}
1980
1981fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
1989 match field {
1990 "root_child_count" => format!("{result_var}.root_node().child_count()"),
1991 "root_node_type" => format!("{result_var}.root_node().kind()"),
1992 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1993 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
1994 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
1995 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
1996 other => format!("{result_var}.{other}"),
1999 }
2000}
2001
2002fn build_tree_call_expr(
2009 field_access: &str,
2010 method_name: &str,
2011 args: Option<&serde_json::Value>,
2012 module: &str,
2013) -> String {
2014 match method_name {
2015 "root_child_count" => format!("{field_access}.root_node().child_count()"),
2016 "root_node_type" => format!("{field_access}.root_node().kind()"),
2017 "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
2018 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
2019 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
2020 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
2021 "contains_node_type" => {
2022 let node_type = args
2023 .and_then(|a| a.get("node_type"))
2024 .and_then(|v| v.as_str())
2025 .unwrap_or("");
2026 format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
2027 }
2028 "find_nodes_by_type" => {
2029 let node_type = args
2030 .and_then(|a| a.get("node_type"))
2031 .and_then(|v| v.as_str())
2032 .unwrap_or("");
2033 format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
2034 }
2035 "run_query" => {
2036 let query_source = args
2037 .and_then(|a| a.get("query_source"))
2038 .and_then(|v| v.as_str())
2039 .unwrap_or("");
2040 let language = args
2041 .and_then(|a| a.get("language"))
2042 .and_then(|v| v.as_str())
2043 .unwrap_or("");
2044 format!(
2047 "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
2048 )
2049 }
2050 _ => {
2052 if let Some(args) = args {
2053 let arg_lit = json_to_rust_literal(args, "");
2054 format!("{field_access}.{method_name}({arg_lit})")
2055 } else {
2056 format!("{field_access}.{method_name}()")
2057 }
2058 }
2059 }
2060}
2061
2062fn is_tree_numeric_method(method_name: &str) -> bool {
2066 matches!(
2067 method_name,
2068 "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
2069 )
2070}
2071
2072fn numeric_literal(value: &serde_json::Value) -> String {
2078 if let Some(n) = value.as_f64() {
2079 if n.fract() == 0.0 {
2080 return format!("{}", n as i64);
2083 }
2084 return format!("{n}_f64");
2085 }
2086 value.to_string()
2088}
2089
2090fn value_to_rust_string(value: &serde_json::Value) -> String {
2091 match value {
2092 serde_json::Value::String(s) => rust_raw_string(s),
2093 serde_json::Value::Bool(b) => format!("{b}"),
2094 serde_json::Value::Number(n) => n.to_string(),
2095 other => {
2096 let s = other.to_string();
2097 format!("\"{s}\"")
2098 }
2099 }
2100}
2101
2102fn resolve_visitor_trait(module: &str) -> String {
2108 if module.contains("html_to_markdown") {
2110 "HtmlVisitor".to_string()
2111 } else {
2112 "Visitor".to_string()
2114 }
2115}
2116
2117fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
2125 let params = match method_name {
2129 "visit_link" => "_: &NodeContext, _: &str, _: &str, _: &str",
2130 "visit_image" => "_: &NodeContext, _: &str, _: &str, _: &str",
2131 "visit_heading" => "_: &NodeContext, _: u8, _: &str, _: Option<&str>",
2132 "visit_code_block" => "_: &NodeContext, _: Option<&str>, _: &str",
2133 "visit_code_inline"
2134 | "visit_strong"
2135 | "visit_emphasis"
2136 | "visit_strikethrough"
2137 | "visit_underline"
2138 | "visit_subscript"
2139 | "visit_superscript"
2140 | "visit_mark"
2141 | "visit_button"
2142 | "visit_summary"
2143 | "visit_figcaption"
2144 | "visit_definition_term"
2145 | "visit_definition_description" => "_: &NodeContext, _: &str",
2146 "visit_text" => "_: &NodeContext, _: &str",
2147 "visit_list_item" => "_: &NodeContext, _: bool, _: &str, _: &str",
2148 "visit_blockquote" => "_: &NodeContext, _: &str, _: u32",
2149 "visit_table_row" => "_: &NodeContext, _: &[String], _: bool",
2150 "visit_custom_element" => "_: &NodeContext, _: &str, _: &str",
2151 "visit_form" => "_: &NodeContext, _: &str, _: &str",
2152 "visit_input" => "_: &NodeContext, _: &str, _: &str, _: &str",
2153 "visit_audio" | "visit_video" | "visit_iframe" => "_: &NodeContext, _: &str",
2154 "visit_details" => "_: &NodeContext, _: bool",
2155 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
2156 "_: &NodeContext, _: &str"
2157 }
2158 "visit_list_start" => "_: &NodeContext, _: bool",
2159 "visit_list_end" => "_: &NodeContext, _: bool, _: &str",
2160 _ => "_: &NodeContext",
2161 };
2162
2163 let _ = writeln!(out, " fn {method_name}(&mut self, {params}) -> VisitResult {{");
2164 match action {
2165 CallbackAction::Skip => {
2166 let _ = writeln!(out, " VisitResult::Skip");
2167 }
2168 CallbackAction::Continue => {
2169 let _ = writeln!(out, " VisitResult::Continue");
2170 }
2171 CallbackAction::PreserveHtml => {
2172 let _ = writeln!(out, " VisitResult::PreserveHtml");
2173 }
2174 CallbackAction::Custom { output } => {
2175 let escaped = escape_rust(output);
2176 let _ = writeln!(out, " VisitResult::Custom(\"{escaped}\".to_string())");
2177 }
2178 CallbackAction::CustomTemplate { template } => {
2179 let escaped = escape_rust(template);
2180 let _ = writeln!(out, " VisitResult::Custom(format!(\"{escaped}\"))");
2181 }
2182 }
2183 let _ = writeln!(out, " }}");
2184}