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
46 .iter()
47 .flat_map(|g| g.fixtures.iter())
48 .any(|f| !is_skipped(f, "rust") && f.needs_mock_server());
49
50 let any_async_call = std::iter::once(&e2e_config.call)
52 .chain(e2e_config.calls.values())
53 .any(|c| c.r#async);
54 let needs_tokio = needs_mock_server || any_async_call;
55
56 let crate_version = resolve_crate_version(e2e_config);
57 files.push(GeneratedFile {
58 path: output_base.join("Cargo.toml"),
59 content: render_cargo_toml(
60 &crate_name,
61 &dep_name,
62 &crate_path,
63 needs_serde_json,
64 needs_mock_server,
65 needs_tokio,
66 e2e_config.dep_mode,
67 crate_version.as_deref(),
68 &alef_config.crate_config.features,
69 ),
70 generated_header: true,
71 });
72
73 if needs_mock_server {
75 files.push(GeneratedFile {
76 path: output_base.join("tests").join("mock_server.rs"),
77 content: render_mock_server_module(),
78 generated_header: true,
79 });
80 files.push(GeneratedFile {
82 path: output_base.join("src").join("main.rs"),
83 content: render_mock_server_binary(),
84 generated_header: true,
85 });
86 }
87
88 for group in groups {
90 let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
91
92 if fixtures.is_empty() {
93 continue;
94 }
95
96 let filename = format!("{}_test.rs", sanitize_filename(&group.category));
97 let content = render_test_file(&group.category, &fixtures, e2e_config, &dep_name, needs_mock_server);
98
99 files.push(GeneratedFile {
100 path: output_base.join("tests").join(filename),
101 content,
102 generated_header: true,
103 });
104 }
105
106 Ok(files)
107 }
108
109 fn language_name(&self) -> &'static str {
110 "rust"
111 }
112}
113
114fn resolve_crate_name(_e2e_config: &E2eConfig, alef_config: &AlefConfig) -> String {
119 alef_config.crate_config.name.clone()
123}
124
125fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
126 e2e_config
127 .resolve_package("rust")
128 .and_then(|p| p.path.clone())
129 .unwrap_or_else(|| format!("../../crates/{crate_name}"))
130}
131
132fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
133 e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
134}
135
136fn resolve_function_name_for_call(call_config: &crate::config::CallConfig) -> String {
137 call_config
138 .overrides
139 .get("rust")
140 .and_then(|o| o.function.clone())
141 .unwrap_or_else(|| call_config.function.clone())
142}
143
144fn resolve_module(e2e_config: &E2eConfig, dep_name: &str) -> String {
145 resolve_module_for_call(&e2e_config.call, dep_name)
146}
147
148fn resolve_module_for_call(call_config: &crate::config::CallConfig, dep_name: &str) -> String {
149 let overrides = call_config.overrides.get("rust");
152 overrides
153 .and_then(|o| o.crate_name.clone())
154 .or_else(|| overrides.and_then(|o| o.module.clone()))
155 .unwrap_or_else(|| dep_name.to_string())
156}
157
158fn is_skipped(fixture: &Fixture, language: &str) -> bool {
159 fixture.skip.as_ref().is_some_and(|s| s.should_skip(language))
160}
161
162#[allow(clippy::too_many_arguments)]
167pub fn render_cargo_toml(
168 crate_name: &str,
169 dep_name: &str,
170 crate_path: &str,
171 needs_serde_json: bool,
172 needs_mock_server: bool,
173 needs_tokio: bool,
174 dep_mode: crate::config::DependencyMode,
175 version: Option<&str>,
176 features: &[String],
177) -> String {
178 let e2e_name = format!("{dep_name}-e2e-rust");
179 let effective_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
183 let features_str = if effective_features.is_empty() {
184 String::new()
185 } else {
186 format!(", default-features = false, features = {:?}", effective_features)
187 };
188 let dep_spec = match dep_mode {
189 crate::config::DependencyMode::Registry => {
190 let ver = version.unwrap_or("0.1.0");
191 if crate_name != dep_name {
192 format!("{dep_name} = {{ package = \"{crate_name}\", version = \"{ver}\"{features_str} }}")
193 } else if effective_features.is_empty() {
194 format!("{dep_name} = \"{ver}\"")
195 } else {
196 format!("{dep_name} = {{ version = \"{ver}\"{features_str} }}")
197 }
198 }
199 crate::config::DependencyMode::Local => {
200 if crate_name != dep_name {
201 format!("{dep_name} = {{ package = \"{crate_name}\", path = \"{crate_path}\"{features_str} }}")
202 } else if effective_features.is_empty() {
203 format!("{dep_name} = {{ path = \"{crate_path}\" }}")
204 } else {
205 format!("{dep_name} = {{ path = \"{crate_path}\"{features_str} }}")
206 }
207 }
208 };
209 let serde_line = if needs_serde_json { "\nserde_json = \"1\"" } else { "" };
210 let mock_lines = if needs_mock_server {
218 format!(
219 "\naxum = \"{axum}\"\ntokio-stream = \"{tokio_stream}\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\nwalkdir = \"{walkdir}\"",
220 axum = tv::cargo::AXUM,
221 tokio_stream = tv::cargo::TOKIO_STREAM,
222 walkdir = tv::cargo::WALKDIR,
223 )
224 } else {
225 String::new()
226 };
227 let mut machete_ignored: Vec<&str> = Vec::new();
228 if needs_serde_json {
229 machete_ignored.push("\"serde_json\"");
230 }
231 if needs_mock_server {
232 machete_ignored.push("\"axum\"");
233 machete_ignored.push("\"tokio-stream\"");
234 machete_ignored.push("\"serde\"");
235 machete_ignored.push("\"walkdir\"");
236 }
237 let machete_section = if machete_ignored.is_empty() {
238 String::new()
239 } else {
240 format!(
241 "\n[package.metadata.cargo-machete]\nignored = [{}]\n",
242 machete_ignored.join(", ")
243 )
244 };
245 let tokio_line = if needs_tokio {
246 "\ntokio = { version = \"1\", features = [\"full\"] }"
247 } else {
248 ""
249 };
250 let bin_section = if needs_mock_server {
251 "\n[[bin]]\nname = \"mock-server\"\npath = \"src/main.rs\"\n"
252 } else {
253 ""
254 };
255 let header = hash::header(CommentStyle::Hash);
256 format!(
257 r#"{header}
258[workspace]
259
260[package]
261name = "{e2e_name}"
262version = "0.1.0"
263edition = "2021"
264license = "MIT"
265publish = false
266{bin_section}
267[dependencies]
268{dep_spec}{serde_line}{mock_lines}{tokio_line}
269{machete_section}"#
270 )
271}
272
273fn render_test_file(
274 category: &str,
275 fixtures: &[&Fixture],
276 e2e_config: &E2eConfig,
277 dep_name: &str,
278 needs_mock_server: bool,
279) -> String {
280 let mut out = String::new();
281 out.push_str(&hash::header(CommentStyle::DoubleSlash));
282 let _ = writeln!(out, "//! E2e tests for category: {category}");
283 let _ = writeln!(out);
284
285 let module = resolve_module(e2e_config, dep_name);
286 let field_resolver = FieldResolver::new(
287 &e2e_config.fields,
288 &e2e_config.fields_optional,
289 &e2e_config.result_fields,
290 &e2e_config.fields_array,
291 );
292
293 let mut imported: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
297 for fixture in fixtures.iter() {
298 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
299 let fn_name = resolve_function_name_for_call(call_config);
300 let mod_name = resolve_module_for_call(call_config, dep_name);
301 imported.insert((mod_name, fn_name));
302 }
303 let mut by_module: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
305 for (mod_name, fn_name) in &imported {
306 by_module.entry(mod_name.clone()).or_default().push(fn_name.clone());
307 }
308 for (mod_name, fns) in &by_module {
309 if fns.len() == 1 {
310 let _ = writeln!(out, "use {mod_name}::{};", fns[0]);
311 } else {
312 let joined = fns.join(", ");
313 let _ = writeln!(out, "use {mod_name}::{{{joined}}};");
314 }
315 }
316
317 let has_handle_args = e2e_config.call.args.iter().any(|a| a.arg_type == "handle");
319 if has_handle_args {
320 let _ = writeln!(out, "use {module}::CrawlConfig;");
321 }
322 for arg in &e2e_config.call.args {
323 if arg.arg_type == "handle" {
324 use heck::ToSnakeCase;
325 let constructor_name = format!("create_{}", arg.name.to_snake_case());
326 let _ = writeln!(out, "use {module}::{constructor_name};");
327 }
328 }
329
330 let file_needs_mock = needs_mock_server && fixtures.iter().any(|f| f.needs_mock_server());
332 if file_needs_mock {
333 let _ = writeln!(out, "mod mock_server;");
334 let _ = writeln!(out, "use mock_server::{{MockRoute, MockServer}};");
335 }
336
337 let file_needs_visitor = fixtures.iter().any(|f| f.visitor.is_some());
341 if file_needs_visitor {
342 let visitor_trait = resolve_visitor_trait(&module);
343 let _ = writeln!(out, "use {module}::{{{visitor_trait}, NodeContext, VisitResult}};");
344 }
345
346 let _ = writeln!(out);
347
348 for fixture in fixtures {
349 render_test_function(&mut out, fixture, e2e_config, dep_name, &field_resolver);
350 let _ = writeln!(out);
351 }
352
353 if !out.ends_with('\n') {
354 out.push('\n');
355 }
356 out
357}
358
359fn render_test_function(
360 out: &mut String,
361 fixture: &Fixture,
362 e2e_config: &E2eConfig,
363 dep_name: &str,
364 field_resolver: &FieldResolver,
365) {
366 let fn_name = sanitize_ident(&fixture.id);
367 let description = &fixture.description;
368 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
369 let function_name = resolve_function_name_for_call(call_config);
370 let module = resolve_module_for_call(call_config, dep_name);
371 let result_var = &call_config.result_var;
372 let has_mock = fixture.needs_mock_server();
373
374 let is_async = call_config.r#async || has_mock;
376 if is_async {
377 let _ = writeln!(out, "#[tokio::test]");
378 let _ = writeln!(out, "async fn test_{fn_name}() {{");
379 } else {
380 let _ = writeln!(out, "#[test]");
381 let _ = writeln!(out, "fn test_{fn_name}() {{");
382 }
383 let _ = writeln!(out, " // {description}");
384
385 if has_mock {
388 render_mock_server_setup(out, fixture, e2e_config);
389 }
390
391 let has_error_assertion = fixture.assertions.iter().any(|a| a.assertion_type == "error");
393
394 let mut arg_exprs: Vec<String> = Vec::new();
396 for arg in &call_config.args {
397 let value = resolve_field(&fixture.input, &arg.field);
398 let var_name = &arg.name;
399 let (bindings, expr) = render_rust_arg(
400 var_name,
401 value,
402 &arg.arg_type,
403 arg.optional,
404 &module,
405 &fixture.id,
406 if has_mock {
407 Some("mock_server.url.as_str()")
408 } else {
409 None
410 },
411 );
412 for binding in &bindings {
413 let _ = writeln!(out, " {binding}");
414 }
415 arg_exprs.push(expr);
416 }
417
418 if let Some(visitor_spec) = &fixture.visitor {
420 let _ = writeln!(out, " struct _TestVisitor;");
421 let _ = writeln!(out, " impl {} for _TestVisitor {{", resolve_visitor_trait(&module));
422 for (method_name, action) in &visitor_spec.callbacks {
423 emit_rust_visitor_method(out, method_name, action);
424 }
425 let _ = writeln!(out, " }}");
426 let _ = writeln!(
427 out,
428 " let visitor = std::rc::Rc::new(std::cell::RefCell::new(_TestVisitor));"
429 );
430 arg_exprs.push("Some(visitor)".to_string());
431 }
432
433 let args_str = arg_exprs.join(", ");
434
435 let await_suffix = if is_async { ".await" } else { "" };
436
437 let result_is_tree = call_config.result_var == "tree";
438
439 if has_error_assertion {
440 let _ = writeln!(out, " let {result_var} = {function_name}({args_str}){await_suffix};");
441 for assertion in &fixture.assertions {
443 render_assertion(
444 out,
445 assertion,
446 result_var,
447 &module,
448 dep_name,
449 true,
450 &[],
451 field_resolver,
452 result_is_tree,
453 );
454 }
455 let _ = writeln!(out, "}}");
456 return;
457 }
458
459 let has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
461
462 let has_usable_assertion = fixture.assertions.iter().any(|a| {
466 if a.assertion_type == "not_error" || a.assertion_type == "error" {
467 return false;
468 }
469 if a.assertion_type == "method_result" {
470 let supported_checks = [
473 "equals",
474 "is_true",
475 "is_false",
476 "greater_than_or_equal",
477 "count_min",
478 "is_error",
479 "contains",
480 "not_empty",
481 "is_empty",
482 ];
483 let check = a.check.as_deref().unwrap_or("is_true");
484 if a.method.is_none() || !supported_checks.contains(&check) {
485 return false;
486 }
487 }
488 match &a.field {
489 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
490 _ => true,
491 }
492 });
493
494 let result_binding = if has_usable_assertion {
495 result_var.to_string()
496 } else {
497 "_".to_string()
498 };
499
500 let has_field_access = fixture
504 .assertions
505 .iter()
506 .any(|a| a.field.as_ref().is_some_and(|f| !f.is_empty()));
507 let only_emptiness_checks = !has_field_access
508 && fixture.assertions.iter().all(|a| {
509 matches!(
510 a.assertion_type.as_str(),
511 "is_empty" | "is_false" | "not_empty" | "is_true" | "not_error"
512 )
513 });
514
515 let unwrap_suffix = if call_config.returns_result {
516 ".expect(\"should succeed\")"
517 } else {
518 ""
519 };
520 if only_emptiness_checks || !call_config.returns_result {
521 let _ = writeln!(
523 out,
524 " let {result_binding} = {function_name}({args_str}){await_suffix};"
525 );
526 } else if has_not_error || !fixture.assertions.is_empty() {
527 let _ = writeln!(
528 out,
529 " let {result_binding} = {function_name}({args_str}){await_suffix}{unwrap_suffix};"
530 );
531 } else {
532 let _ = writeln!(
533 out,
534 " let {result_binding} = {function_name}({args_str}){await_suffix};"
535 );
536 }
537
538 let string_assertion_types = [
541 "equals",
542 "contains",
543 "contains_all",
544 "contains_any",
545 "not_contains",
546 "starts_with",
547 "ends_with",
548 "min_length",
549 "max_length",
550 "matches_regex",
551 ];
552 let mut unwrapped_fields: Vec<(String, String)> = Vec::new(); for assertion in &fixture.assertions {
554 if let Some(f) = &assertion.field {
555 if !f.is_empty()
556 && string_assertion_types.contains(&assertion.assertion_type.as_str())
557 && !unwrapped_fields.iter().any(|(ff, _)| ff == f)
558 {
559 let is_string_assertion = assertion.value.as_ref().is_none_or(|v| v.is_string());
562 if !is_string_assertion {
563 continue;
564 }
565 if let Some((binding, local_var)) = field_resolver.rust_unwrap_binding(f, result_var) {
566 let _ = writeln!(out, " {binding}");
567 unwrapped_fields.push((f.clone(), local_var));
568 }
569 }
570 }
571 }
572
573 for assertion in &fixture.assertions {
575 if assertion.assertion_type == "not_error" {
576 continue;
578 }
579 render_assertion(
580 out,
581 assertion,
582 result_var,
583 &module,
584 dep_name,
585 false,
586 &unwrapped_fields,
587 field_resolver,
588 result_is_tree,
589 );
590 }
591
592 let _ = writeln!(out, "}}");
593}
594
595fn render_rust_arg(
600 name: &str,
601 value: &serde_json::Value,
602 arg_type: &str,
603 optional: bool,
604 module: &str,
605 fixture_id: &str,
606 mock_base_url: Option<&str>,
607) -> (Vec<String>, String) {
608 if arg_type == "mock_url" {
609 let lines = vec![format!(
610 "let {name} = format!(\"{{}}/fixtures/{{}}\", std::env::var(\"MOCK_SERVER_URL\").expect(\"MOCK_SERVER_URL not set\"), \"{fixture_id}\");"
611 )];
612 return (lines, format!("&{name}"));
613 }
614 if arg_type == "base_url" {
616 if let Some(url_expr) = mock_base_url {
617 return (vec![], url_expr.to_string());
618 }
619 }
621 if arg_type == "handle" {
622 use heck::ToSnakeCase;
626 let constructor_name = format!("create_{}", name.to_snake_case());
627 let mut lines = Vec::new();
628 if value.is_null() || value.is_object() && value.as_object().unwrap().is_empty() {
629 lines.push(format!(
630 "let {name} = {constructor_name}(None).expect(\"handle creation should succeed\");"
631 ));
632 } else {
633 let json_literal = serde_json::to_string(value).unwrap_or_default();
635 let escaped = json_literal.replace('\\', "\\\\").replace('"', "\\\"");
636 lines.push(format!(
637 "let {name}_config: CrawlConfig = serde_json::from_str(\"{escaped}\").expect(\"config should parse\");"
638 ));
639 lines.push(format!(
640 "let {name} = {constructor_name}(Some({name}_config)).expect(\"handle creation should succeed\");"
641 ));
642 }
643 return (lines, format!("&{name}"));
644 }
645 if arg_type == "json_object" {
646 return render_json_object_arg(name, value, optional, module);
647 }
648 if value.is_null() && !optional {
649 let default_val = match arg_type {
651 "string" => "String::new()".to_string(),
652 "int" | "integer" => "0".to_string(),
653 "float" | "number" => "0.0_f64".to_string(),
654 "bool" | "boolean" => "false".to_string(),
655 _ => "Default::default()".to_string(),
656 };
657 let expr = if arg_type == "string" {
659 format!("&{name}")
660 } else {
661 name.to_string()
662 };
663 return (vec![format!("let {name} = {default_val};")], expr);
664 }
665 let literal = json_to_rust_literal(value, arg_type);
666 let pass_by_ref = arg_type == "string" || arg_type == "bytes";
669 let optional_expr = |n: &str| {
670 if arg_type == "string" {
671 format!("{n}.as_deref()")
672 } else if arg_type == "bytes" {
673 format!("{n}.as_deref().map(|v| v.as_slice())")
674 } else {
675 format!("{n}.as_ref()")
676 }
677 };
678 let expr = |n: &str| {
679 if arg_type == "bytes" {
680 format!("{n}.as_bytes()")
681 } else if pass_by_ref {
682 format!("&{n}")
683 } else {
684 n.to_string()
685 }
686 };
687 if optional && value.is_null() {
688 let none_decl = match arg_type {
689 "string" => format!("let {name}: Option<String> = None;"),
690 "bytes" => format!("let {name}: Option<Vec<u8>> = None;"),
691 _ => format!("let {name} = None;"),
692 };
693 (vec![none_decl], optional_expr(name))
694 } else if optional {
695 (vec![format!("let {name} = Some({literal});")], optional_expr(name))
696 } else {
697 (vec![format!("let {name} = {literal};")], expr(name))
698 }
699}
700
701fn render_json_object_arg(
705 name: &str,
706 value: &serde_json::Value,
707 optional: bool,
708 _module: &str,
709) -> (Vec<String>, String) {
710 if value.is_null() && optional {
711 return (vec![format!("let {name} = Default::default();")], format!("&{name}"));
714 }
715
716 let normalized = super::normalize_json_keys_to_snake_case(value);
719 let json_literal = json_value_to_macro_literal(&normalized);
721 let mut lines = Vec::new();
722 lines.push(format!("let {name}_json = serde_json::json!({json_literal});"));
723 let deser_expr = format!("serde_json::from_value({name}_json).unwrap()");
725 if optional {
726 lines.push(format!("let {name} = Some({deser_expr});"));
727 (lines, format!("&{name}"))
728 } else {
729 lines.push(format!("let {name} = {deser_expr};"));
730 (lines, format!("&{name}"))
731 }
732}
733
734fn json_value_to_macro_literal(value: &serde_json::Value) -> String {
736 match value {
737 serde_json::Value::Null => "null".to_string(),
738 serde_json::Value::Bool(b) => format!("{b}"),
739 serde_json::Value::Number(n) => n.to_string(),
740 serde_json::Value::String(s) => {
741 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
742 format!("\"{escaped}\"")
743 }
744 serde_json::Value::Array(arr) => {
745 let items: Vec<String> = arr.iter().map(json_value_to_macro_literal).collect();
746 format!("[{}]", items.join(", "))
747 }
748 serde_json::Value::Object(obj) => {
749 let entries: Vec<String> = obj
750 .iter()
751 .map(|(k, v)| {
752 let escaped_key = k.replace('\\', "\\\\").replace('"', "\\\"");
753 format!("\"{escaped_key}\": {}", json_value_to_macro_literal(v))
754 })
755 .collect();
756 format!("{{{}}}", entries.join(", "))
757 }
758 }
759}
760
761fn json_to_rust_literal(value: &serde_json::Value, arg_type: &str) -> String {
762 match value {
763 serde_json::Value::Null => "None".to_string(),
764 serde_json::Value::Bool(b) => format!("{b}"),
765 serde_json::Value::Number(n) => {
766 if arg_type.contains("float") || arg_type.contains("f64") || arg_type.contains("f32") {
767 if let Some(f) = n.as_f64() {
768 return format!("{f}_f64");
769 }
770 }
771 n.to_string()
772 }
773 serde_json::Value::String(s) => rust_raw_string(s),
774 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
775 let json_str = serde_json::to_string(value).unwrap_or_default();
776 let literal = rust_raw_string(&json_str);
777 format!("serde_json::from_str({literal}).unwrap()")
778 }
779 }
780}
781
782fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
792 let mock = match fixture.mock_response.as_ref() {
793 Some(m) => m,
794 None => return,
795 };
796
797 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
799 let path = call_config.path.as_deref().unwrap_or("/");
800 let method = call_config.method.as_deref().unwrap_or("POST");
801
802 let status = mock.status;
803
804 if let Some(chunks) = &mock.stream_chunks {
805 let _ = writeln!(out, " let mock_route = MockRoute {{");
807 let _ = writeln!(out, " path: \"{path}\",");
808 let _ = writeln!(out, " method: \"{method}\",");
809 let _ = writeln!(out, " status: {status},");
810 let _ = writeln!(out, " body: String::new(),");
811 let _ = writeln!(out, " stream_chunks: vec![");
812 for chunk in chunks {
813 let chunk_str = match chunk {
814 serde_json::Value::String(s) => rust_raw_string(s),
815 other => {
816 let s = serde_json::to_string(other).unwrap_or_default();
817 rust_raw_string(&s)
818 }
819 };
820 let _ = writeln!(out, " {chunk_str}.to_string(),");
821 }
822 let _ = writeln!(out, " ],");
823 let _ = writeln!(out, " }};");
824 } else {
825 let body_str = match &mock.body {
827 Some(b) => {
828 let s = serde_json::to_string(b).unwrap_or_default();
829 rust_raw_string(&s)
830 }
831 None => rust_raw_string("{}"),
832 };
833 let _ = writeln!(out, " let mock_route = MockRoute {{");
834 let _ = writeln!(out, " path: \"{path}\",");
835 let _ = writeln!(out, " method: \"{method}\",");
836 let _ = writeln!(out, " status: {status},");
837 let _ = writeln!(out, " body: {body_str}.to_string(),");
838 let _ = writeln!(out, " stream_chunks: vec![],");
839 let _ = writeln!(out, " }};");
840 }
841
842 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
843}
844
845fn render_mock_server_module() -> String {
847 hash::header(CommentStyle::DoubleSlash)
850 + r#"//
851// Minimal axum-based mock HTTP server for e2e tests.
852
853use std::net::SocketAddr;
854use std::sync::Arc;
855
856use axum::Router;
857use axum::body::Body;
858use axum::extract::State;
859use axum::http::{Request, StatusCode};
860use axum::response::{IntoResponse, Response};
861use tokio::net::TcpListener;
862
863/// A single mock route: match by path + method, return a configured response.
864#[derive(Clone, Debug)]
865pub struct MockRoute {
866 /// URL path to match, e.g. `"/v1/chat/completions"`.
867 pub path: &'static str,
868 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
869 pub method: &'static str,
870 /// HTTP status code to return.
871 pub status: u16,
872 /// Response body JSON string (used when `stream_chunks` is empty).
873 pub body: String,
874 /// Ordered SSE data payloads for streaming responses.
875 /// Each entry becomes `data: <chunk>\n\n` in the response.
876 /// A final `data: [DONE]\n\n` is always appended.
877 pub stream_chunks: Vec<String>,
878}
879
880struct ServerState {
881 routes: Vec<MockRoute>,
882}
883
884pub struct MockServer {
885 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
886 pub url: String,
887 handle: tokio::task::JoinHandle<()>,
888}
889
890impl MockServer {
891 /// Start a mock server with the given routes. Binds to a random port on
892 /// localhost and returns immediately once the server is listening.
893 pub async fn start(routes: Vec<MockRoute>) -> Self {
894 let state = Arc::new(ServerState { routes });
895
896 let app = Router::new().fallback(handle_request).with_state(state);
897
898 let listener = TcpListener::bind("127.0.0.1:0")
899 .await
900 .expect("Failed to bind mock server port");
901 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
902 let url = format!("http://{addr}");
903
904 let handle = tokio::spawn(async move {
905 axum::serve(listener, app).await.expect("Mock server failed");
906 });
907
908 MockServer { url, handle }
909 }
910
911 /// Stop the mock server.
912 pub fn shutdown(self) {
913 self.handle.abort();
914 }
915}
916
917impl Drop for MockServer {
918 fn drop(&mut self) {
919 self.handle.abort();
920 }
921}
922
923async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
924 let path = req.uri().path().to_owned();
925 let method = req.method().as_str().to_uppercase();
926
927 for route in &state.routes {
928 if route.path == path && route.method.to_uppercase() == method {
929 let status =
930 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
931
932 if !route.stream_chunks.is_empty() {
933 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
934 let mut sse = String::new();
935 for chunk in &route.stream_chunks {
936 sse.push_str("data: ");
937 sse.push_str(chunk);
938 sse.push_str("\n\n");
939 }
940 sse.push_str("data: [DONE]\n\n");
941
942 return Response::builder()
943 .status(status)
944 .header("content-type", "text/event-stream")
945 .header("cache-control", "no-cache")
946 .body(Body::from(sse))
947 .unwrap()
948 .into_response();
949 }
950
951 return Response::builder()
952 .status(status)
953 .header("content-type", "application/json")
954 .body(Body::from(route.body.clone()))
955 .unwrap()
956 .into_response();
957 }
958 }
959
960 // No matching route → 404.
961 Response::builder()
962 .status(StatusCode::NOT_FOUND)
963 .body(Body::from(format!("No mock route for {method} {path}")))
964 .unwrap()
965 .into_response()
966}
967"#
968}
969
970fn render_mock_server_binary() -> String {
982 hash::header(CommentStyle::DoubleSlash)
983 + r#"//
984// Standalone mock HTTP server binary for cross-language e2e tests.
985// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
986//
987// Usage: mock-server [fixtures-dir]
988// fixtures-dir defaults to "../../fixtures"
989//
990// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
991// then blocks until stdin is closed (parent process exit triggers cleanup).
992
993use std::collections::HashMap;
994use std::io::{self, BufRead};
995use std::net::SocketAddr;
996use std::path::Path;
997use std::sync::Arc;
998
999use axum::Router;
1000use axum::body::Body;
1001use axum::extract::State;
1002use axum::http::{Request, StatusCode};
1003use axum::response::{IntoResponse, Response};
1004use serde::Deserialize;
1005use tokio::net::TcpListener;
1006
1007// ---------------------------------------------------------------------------
1008// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
1009// ---------------------------------------------------------------------------
1010
1011#[derive(Debug, Deserialize)]
1012struct MockResponse {
1013 status: u16,
1014 #[serde(default)]
1015 body: Option<serde_json::Value>,
1016 #[serde(default)]
1017 stream_chunks: Option<Vec<serde_json::Value>>,
1018}
1019
1020#[derive(Debug, Deserialize)]
1021struct Fixture {
1022 id: String,
1023 #[serde(default)]
1024 mock_response: Option<MockResponse>,
1025}
1026
1027// ---------------------------------------------------------------------------
1028// Route table
1029// ---------------------------------------------------------------------------
1030
1031#[derive(Clone, Debug)]
1032struct MockRoute {
1033 status: u16,
1034 body: String,
1035 stream_chunks: Vec<String>,
1036}
1037
1038type RouteTable = Arc<HashMap<String, MockRoute>>;
1039
1040// ---------------------------------------------------------------------------
1041// Axum handler
1042// ---------------------------------------------------------------------------
1043
1044async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
1045 let path = req.uri().path().to_owned();
1046
1047 // Try exact match first
1048 if let Some(route) = routes.get(&path) {
1049 return serve_route(route);
1050 }
1051
1052 // Try prefix match: find a route that is a prefix of the request path
1053 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
1054 for (route_path, route) in routes.iter() {
1055 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
1056 return serve_route(route);
1057 }
1058 }
1059
1060 Response::builder()
1061 .status(StatusCode::NOT_FOUND)
1062 .body(Body::from(format!("No mock route for {path}")))
1063 .unwrap()
1064 .into_response()
1065}
1066
1067fn serve_route(route: &MockRoute) -> Response {
1068 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1069
1070 if !route.stream_chunks.is_empty() {
1071 let mut sse = String::new();
1072 for chunk in &route.stream_chunks {
1073 sse.push_str("data: ");
1074 sse.push_str(chunk);
1075 sse.push_str("\n\n");
1076 }
1077 sse.push_str("data: [DONE]\n\n");
1078
1079 return Response::builder()
1080 .status(status)
1081 .header("content-type", "text/event-stream")
1082 .header("cache-control", "no-cache")
1083 .body(Body::from(sse))
1084 .unwrap()
1085 .into_response();
1086 }
1087
1088 Response::builder()
1089 .status(status)
1090 .header("content-type", "application/json")
1091 .body(Body::from(route.body.clone()))
1092 .unwrap()
1093 .into_response()
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Fixture loading
1098// ---------------------------------------------------------------------------
1099
1100fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
1101 let mut routes = HashMap::new();
1102 load_routes_recursive(fixtures_dir, &mut routes);
1103 routes
1104}
1105
1106fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
1107 let entries = match std::fs::read_dir(dir) {
1108 Ok(e) => e,
1109 Err(err) => {
1110 eprintln!("warning: cannot read directory {}: {err}", dir.display());
1111 return;
1112 }
1113 };
1114
1115 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
1116 paths.sort();
1117
1118 for path in paths {
1119 if path.is_dir() {
1120 load_routes_recursive(&path, routes);
1121 } else if path.extension().is_some_and(|ext| ext == "json") {
1122 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1123 if filename == "schema.json" || filename.starts_with('_') {
1124 continue;
1125 }
1126 let content = match std::fs::read_to_string(&path) {
1127 Ok(c) => c,
1128 Err(err) => {
1129 eprintln!("warning: cannot read {}: {err}", path.display());
1130 continue;
1131 }
1132 };
1133 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
1134 match serde_json::from_str(&content) {
1135 Ok(v) => v,
1136 Err(err) => {
1137 eprintln!("warning: cannot parse {}: {err}", path.display());
1138 continue;
1139 }
1140 }
1141 } else {
1142 match serde_json::from_str::<Fixture>(&content) {
1143 Ok(f) => vec![f],
1144 Err(err) => {
1145 eprintln!("warning: cannot parse {}: {err}", path.display());
1146 continue;
1147 }
1148 }
1149 };
1150
1151 for fixture in fixtures {
1152 if let Some(mock) = fixture.mock_response {
1153 let route_path = format!("/fixtures/{}", fixture.id);
1154 let body = mock
1155 .body
1156 .as_ref()
1157 .map(|b| serde_json::to_string(b).unwrap_or_default())
1158 .unwrap_or_default();
1159 let stream_chunks = mock
1160 .stream_chunks
1161 .unwrap_or_default()
1162 .into_iter()
1163 .map(|c| match c {
1164 serde_json::Value::String(s) => s,
1165 other => serde_json::to_string(&other).unwrap_or_default(),
1166 })
1167 .collect();
1168 routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks });
1169 }
1170 }
1171 }
1172 }
1173}
1174
1175// ---------------------------------------------------------------------------
1176// Entry point
1177// ---------------------------------------------------------------------------
1178
1179#[tokio::main]
1180async fn main() {
1181 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1182 let fixtures_dir = Path::new(&fixtures_dir_arg);
1183
1184 let routes = load_routes(fixtures_dir);
1185 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
1186
1187 let route_table: RouteTable = Arc::new(routes);
1188 let app = Router::new().fallback(handle_request).with_state(route_table);
1189
1190 let listener = TcpListener::bind("127.0.0.1:0")
1191 .await
1192 .expect("mock-server: failed to bind port");
1193 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
1194
1195 // Print the URL so the parent process can read it.
1196 println!("MOCK_SERVER_URL=http://{addr}");
1197 // Flush stdout explicitly so the parent does not block waiting.
1198 use std::io::Write;
1199 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1200
1201 // Spawn the server in the background.
1202 tokio::spawn(async move {
1203 axum::serve(listener, app).await.expect("mock-server: server error");
1204 });
1205
1206 // Block until stdin is closed — the parent process controls lifetime.
1207 let stdin = io::stdin();
1208 let mut lines = stdin.lock().lines();
1209 while lines.next().is_some() {}
1210}
1211"#
1212}
1213
1214#[allow(clippy::too_many_arguments)]
1219fn render_assertion(
1220 out: &mut String,
1221 assertion: &Assertion,
1222 result_var: &str,
1223 module: &str,
1224 _dep_name: &str,
1225 is_error_context: bool,
1226 unwrapped_fields: &[(String, String)], field_resolver: &FieldResolver,
1228 result_is_tree: bool,
1229) {
1230 if let Some(f) = &assertion.field {
1232 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1233 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1234 return;
1235 }
1236 }
1237
1238 let field_access = match &assertion.field {
1243 Some(f) if !f.is_empty() => {
1244 if let Some((_, local_var)) = unwrapped_fields.iter().find(|(ff, _)| ff == f) {
1245 local_var.clone()
1246 } else if result_is_tree {
1247 tree_field_access_expr(f, result_var, module)
1250 } else {
1251 field_resolver.accessor(f, "rust", result_var)
1252 }
1253 }
1254 _ => result_var.to_string(),
1255 };
1256
1257 let is_unwrapped = assertion
1259 .field
1260 .as_ref()
1261 .is_some_and(|f| unwrapped_fields.iter().any(|(ff, _)| ff == f));
1262
1263 match assertion.assertion_type.as_str() {
1264 "error" => {
1265 let _ = writeln!(out, " assert!({result_var}.is_err(), \"expected call to fail\");");
1266 if let Some(serde_json::Value::String(msg)) = &assertion.value {
1267 let escaped = escape_rust(msg);
1268 let _ = writeln!(
1269 out,
1270 " assert!({result_var}.as_ref().unwrap_err().to_string().contains(\"{escaped}\"), \"error message mismatch\");"
1271 );
1272 }
1273 }
1274 "not_error" => {
1275 }
1277 "equals" => {
1278 if let Some(val) = &assertion.value {
1279 let expected = value_to_rust_string(val);
1280 if is_error_context {
1281 return;
1282 }
1283 if val.is_string() {
1286 let _ = writeln!(
1287 out,
1288 " assert_eq!({field_access}.trim(), {expected}, \"equals assertion failed\");"
1289 );
1290 } else if val.is_boolean() {
1291 if val.as_bool() == Some(true) {
1293 let _ = writeln!(out, " assert!({field_access}, \"equals assertion failed\");");
1294 } else {
1295 let _ = writeln!(out, " assert!(!{field_access}, \"equals assertion failed\");");
1296 }
1297 } else {
1298 let is_opt = assertion.field.as_ref().is_some_and(|f| {
1300 let resolved = field_resolver.resolve(f);
1301 field_resolver.is_optional(resolved)
1302 });
1303 if is_opt
1304 && !unwrapped_fields
1305 .iter()
1306 .any(|(ff, _)| assertion.field.as_ref() == Some(ff))
1307 {
1308 let _ = writeln!(
1309 out,
1310 " assert_eq!({field_access}, Some({expected}), \"equals assertion failed\");"
1311 );
1312 } else {
1313 let _ = writeln!(
1314 out,
1315 " assert_eq!({field_access}, {expected}, \"equals assertion failed\");"
1316 );
1317 }
1318 }
1319 }
1320 }
1321 "contains" => {
1322 if let Some(val) = &assertion.value {
1323 let expected = value_to_rust_string(val);
1324 let line = format!(
1325 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1326 );
1327 let _ = writeln!(out, "{line}");
1328 }
1329 }
1330 "contains_all" => {
1331 if let Some(values) = &assertion.values {
1332 for val in values {
1333 let expected = value_to_rust_string(val);
1334 let line = format!(
1335 " assert!(format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected to contain: {{}}\", {expected});"
1336 );
1337 let _ = writeln!(out, "{line}");
1338 }
1339 }
1340 }
1341 "not_contains" => {
1342 if let Some(val) = &assertion.value {
1343 let expected = value_to_rust_string(val);
1344 let line = format!(
1345 " assert!(!format!(\"{{:?}}\", {field_access}).contains({expected}), \"expected NOT to contain: {{}}\", {expected});"
1346 );
1347 let _ = writeln!(out, "{line}");
1348 }
1349 }
1350 "not_empty" => {
1351 if let Some(f) = &assertion.field {
1352 let resolved = field_resolver.resolve(f);
1353 if !is_unwrapped && field_resolver.is_optional(resolved) {
1354 let accessor = field_resolver.accessor(f, "rust", result_var);
1356 let _ = writeln!(
1357 out,
1358 " assert!({accessor}.is_some(), \"expected {f} to be present\");"
1359 );
1360 } else {
1361 let _ = writeln!(
1362 out,
1363 " assert!(!{field_access}.is_empty(), \"expected non-empty value\");"
1364 );
1365 }
1366 } else {
1367 let _ = writeln!(
1369 out,
1370 " assert!({field_access}.is_some(), \"expected non-empty value\");"
1371 );
1372 }
1373 }
1374 "is_empty" => {
1375 if let Some(f) = &assertion.field {
1376 let resolved = field_resolver.resolve(f);
1377 if !is_unwrapped && field_resolver.is_optional(resolved) {
1378 let accessor = field_resolver.accessor(f, "rust", result_var);
1379 let _ = writeln!(out, " assert!({accessor}.is_none(), \"expected {f} to be absent\");");
1380 } else {
1381 let _ = writeln!(out, " assert!({field_access}.is_empty(), \"expected empty value\");");
1382 }
1383 } else {
1384 let _ = writeln!(out, " assert!({field_access}.is_none(), \"expected empty value\");");
1386 }
1387 }
1388 "contains_any" => {
1389 if let Some(values) = &assertion.values {
1390 let checks: Vec<String> = values
1391 .iter()
1392 .map(|v| {
1393 let expected = value_to_rust_string(v);
1394 format!("{field_access}.contains({expected})")
1395 })
1396 .collect();
1397 let joined = checks.join(" || ");
1398 let _ = writeln!(
1399 out,
1400 " assert!({joined}, \"expected to contain at least one of the specified values\");"
1401 );
1402 }
1403 }
1404 "greater_than" => {
1405 if let Some(val) = &assertion.value {
1406 if val.as_f64().is_some_and(|n| n < 0.0) {
1408 let _ = writeln!(
1409 out,
1410 " // skipped: greater_than with negative value is always true for unsigned types"
1411 );
1412 } else if val.as_u64() == Some(0) {
1413 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1415 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected > 0\");");
1416 } else {
1417 let lit = numeric_literal(val);
1418 let _ = writeln!(out, " assert!({field_access} > {lit}, \"expected > {lit}\");");
1419 }
1420 }
1421 }
1422 "less_than" => {
1423 if let Some(val) = &assertion.value {
1424 let lit = numeric_literal(val);
1425 let _ = writeln!(out, " assert!({field_access} < {lit}, \"expected < {lit}\");");
1426 }
1427 }
1428 "greater_than_or_equal" => {
1429 if let Some(val) = &assertion.value {
1430 let lit = numeric_literal(val);
1431 if val.as_u64() == Some(1) && field_access.ends_with(".len()") {
1432 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1436 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= 1\");");
1437 } else {
1438 let _ = writeln!(out, " assert!({field_access} >= {lit}, \"expected >= {lit}\");");
1439 }
1440 }
1441 }
1442 "less_than_or_equal" => {
1443 if let Some(val) = &assertion.value {
1444 let lit = numeric_literal(val);
1445 let _ = writeln!(out, " assert!({field_access} <= {lit}, \"expected <= {lit}\");");
1446 }
1447 }
1448 "starts_with" => {
1449 if let Some(val) = &assertion.value {
1450 let expected = value_to_rust_string(val);
1451 let _ = writeln!(
1452 out,
1453 " assert!({field_access}.starts_with({expected}), \"expected to start with: {{}}\", {expected});"
1454 );
1455 }
1456 }
1457 "ends_with" => {
1458 if let Some(val) = &assertion.value {
1459 let expected = value_to_rust_string(val);
1460 let _ = writeln!(
1461 out,
1462 " assert!({field_access}.ends_with({expected}), \"expected to end with: {{}}\", {expected});"
1463 );
1464 }
1465 }
1466 "min_length" => {
1467 if let Some(val) = &assertion.value {
1468 if let Some(n) = val.as_u64() {
1469 let _ = writeln!(
1470 out,
1471 " assert!({field_access}.len() >= {n}, \"expected length >= {n}, got {{}}\", {field_access}.len());"
1472 );
1473 }
1474 }
1475 }
1476 "max_length" => {
1477 if let Some(val) = &assertion.value {
1478 if let Some(n) = val.as_u64() {
1479 let _ = writeln!(
1480 out,
1481 " assert!({field_access}.len() <= {n}, \"expected length <= {n}, got {{}}\", {field_access}.len());"
1482 );
1483 }
1484 }
1485 }
1486 "count_min" => {
1487 if let Some(val) = &assertion.value {
1488 if let Some(n) = val.as_u64() {
1489 if n <= 1 {
1490 let base = field_access.strip_suffix(".len()").unwrap_or(&field_access);
1492 let _ = writeln!(out, " assert!(!{base}.is_empty(), \"expected >= {n}\");");
1493 } else {
1494 let _ = writeln!(
1495 out,
1496 " assert!({field_access}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {field_access}.len());"
1497 );
1498 }
1499 }
1500 }
1501 }
1502 "count_equals" => {
1503 if let Some(val) = &assertion.value {
1504 if let Some(n) = val.as_u64() {
1505 let _ = writeln!(
1506 out,
1507 " assert_eq!({field_access}.len(), {n}, \"expected exactly {n} elements, got {{}}\", {field_access}.len());"
1508 );
1509 }
1510 }
1511 }
1512 "is_true" => {
1513 let _ = writeln!(out, " assert!({field_access}, \"expected true\");");
1514 }
1515 "is_false" => {
1516 let _ = writeln!(out, " assert!(!{field_access}, \"expected false\");");
1517 }
1518 "method_result" => {
1519 if let Some(method_name) = &assertion.method {
1520 let call_expr = if result_is_tree {
1524 build_tree_call_expr(field_access.as_str(), method_name, assertion.args.as_ref(), module)
1525 } else if let Some(args) = &assertion.args {
1526 let arg_lit = json_to_rust_literal(args, "");
1527 format!("{field_access}.{method_name}({arg_lit})")
1528 } else {
1529 format!("{field_access}.{method_name}()")
1530 };
1531
1532 let returns_numeric = result_is_tree && is_tree_numeric_method(method_name);
1535
1536 let check = assertion.check.as_deref().unwrap_or("is_true");
1537 match check {
1538 "equals" => {
1539 if let Some(val) = &assertion.value {
1540 if val.is_boolean() {
1541 if val.as_bool() == Some(true) {
1542 let _ = writeln!(
1543 out,
1544 " assert!({call_expr}, \"method_result equals assertion failed\");"
1545 );
1546 } else {
1547 let _ = writeln!(
1548 out,
1549 " assert!(!{call_expr}, \"method_result equals assertion failed\");"
1550 );
1551 }
1552 } else {
1553 let expected = value_to_rust_string(val);
1554 let _ = writeln!(
1555 out,
1556 " assert_eq!({call_expr}, {expected}, \"method_result equals assertion failed\");"
1557 );
1558 }
1559 }
1560 }
1561 "is_true" => {
1562 let _ = writeln!(
1563 out,
1564 " assert!({call_expr}, \"method_result is_true assertion failed\");"
1565 );
1566 }
1567 "is_false" => {
1568 let _ = writeln!(
1569 out,
1570 " assert!(!{call_expr}, \"method_result is_false assertion failed\");"
1571 );
1572 }
1573 "greater_than_or_equal" => {
1574 if let Some(val) = &assertion.value {
1575 let lit = numeric_literal(val);
1576 if returns_numeric {
1577 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1579 } else if val.as_u64() == Some(1) {
1580 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= 1\");");
1582 } else {
1583 let _ = writeln!(out, " assert!({call_expr} >= {lit}, \"expected >= {lit}\");");
1584 }
1585 }
1586 }
1587 "count_min" => {
1588 if let Some(val) = &assertion.value {
1589 let n = val.as_u64().unwrap_or(0);
1590 if n <= 1 {
1591 let _ = writeln!(out, " assert!(!{call_expr}.is_empty(), \"expected >= {n}\");");
1592 } else {
1593 let _ = writeln!(
1594 out,
1595 " assert!({call_expr}.len() >= {n}, \"expected at least {n} elements, got {{}}\", {call_expr}.len());"
1596 );
1597 }
1598 }
1599 }
1600 "is_error" => {
1601 let raw_call = call_expr.strip_suffix(".unwrap()").unwrap_or(&call_expr);
1603 let _ = writeln!(
1604 out,
1605 " assert!({raw_call}.is_err(), \"expected method to return error\");"
1606 );
1607 }
1608 "contains" => {
1609 if let Some(val) = &assertion.value {
1610 let expected = value_to_rust_string(val);
1611 let _ = writeln!(
1612 out,
1613 " assert!({call_expr}.contains({expected}), \"expected result to contain {{}}\", {expected});"
1614 );
1615 }
1616 }
1617 "not_empty" => {
1618 let _ = writeln!(
1619 out,
1620 " assert!(!{call_expr}.is_empty(), \"expected non-empty result\");"
1621 );
1622 }
1623 "is_empty" => {
1624 let _ = writeln!(out, " assert!({call_expr}.is_empty(), \"expected empty result\");");
1625 }
1626 other_check => {
1627 panic!("Rust e2e generator: unsupported method_result check type: {other_check}");
1628 }
1629 }
1630 } else {
1631 panic!("Rust e2e generator: method_result assertion missing 'method' field");
1632 }
1633 }
1634 other => {
1635 panic!("Rust e2e generator: unsupported assertion type: {other}");
1636 }
1637 }
1638}
1639
1640fn tree_field_access_expr(field: &str, result_var: &str, module: &str) -> String {
1648 match field {
1649 "root_child_count" => format!("{result_var}.root_node().child_count()"),
1650 "root_node_type" => format!("{result_var}.root_node().kind()"),
1651 "named_children_count" => format!("{result_var}.root_node().named_child_count()"),
1652 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{result_var})"),
1653 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{result_var})"),
1654 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{result_var})"),
1655 other => format!("{result_var}.{other}"),
1658 }
1659}
1660
1661fn build_tree_call_expr(
1668 field_access: &str,
1669 method_name: &str,
1670 args: Option<&serde_json::Value>,
1671 module: &str,
1672) -> String {
1673 match method_name {
1674 "root_child_count" => format!("{field_access}.root_node().child_count()"),
1675 "root_node_type" => format!("{field_access}.root_node().kind()"),
1676 "named_children_count" => format!("{field_access}.root_node().named_child_count()"),
1677 "has_error_nodes" => format!("{module}::tree_has_error_nodes(&{field_access})"),
1678 "error_count" | "tree_error_count" => format!("{module}::tree_error_count(&{field_access})"),
1679 "tree_to_sexp" => format!("{module}::tree_to_sexp(&{field_access})"),
1680 "contains_node_type" => {
1681 let node_type = args
1682 .and_then(|a| a.get("node_type"))
1683 .and_then(|v| v.as_str())
1684 .unwrap_or("");
1685 format!("{module}::tree_contains_node_type(&{field_access}, \"{node_type}\")")
1686 }
1687 "find_nodes_by_type" => {
1688 let node_type = args
1689 .and_then(|a| a.get("node_type"))
1690 .and_then(|v| v.as_str())
1691 .unwrap_or("");
1692 format!("{module}::find_nodes_by_type(&{field_access}, \"{node_type}\")")
1693 }
1694 "run_query" => {
1695 let query_source = args
1696 .and_then(|a| a.get("query_source"))
1697 .and_then(|v| v.as_str())
1698 .unwrap_or("");
1699 let language = args
1700 .and_then(|a| a.get("language"))
1701 .and_then(|v| v.as_str())
1702 .unwrap_or("");
1703 format!(
1706 "{module}::run_query(&{field_access}, \"{language}\", r#\"{query_source}\"#, source.as_bytes()).unwrap()"
1707 )
1708 }
1709 _ => {
1711 if let Some(args) = args {
1712 let arg_lit = json_to_rust_literal(args, "");
1713 format!("{field_access}.{method_name}({arg_lit})")
1714 } else {
1715 format!("{field_access}.{method_name}()")
1716 }
1717 }
1718 }
1719}
1720
1721fn is_tree_numeric_method(method_name: &str) -> bool {
1725 matches!(
1726 method_name,
1727 "root_child_count" | "named_children_count" | "error_count" | "tree_error_count"
1728 )
1729}
1730
1731fn numeric_literal(value: &serde_json::Value) -> String {
1737 if let Some(n) = value.as_f64() {
1738 if n.fract() == 0.0 {
1739 return format!("{}", n as i64);
1742 }
1743 return format!("{n}_f64");
1744 }
1745 value.to_string()
1747}
1748
1749fn value_to_rust_string(value: &serde_json::Value) -> String {
1750 match value {
1751 serde_json::Value::String(s) => rust_raw_string(s),
1752 serde_json::Value::Bool(b) => format!("{b}"),
1753 serde_json::Value::Number(n) => n.to_string(),
1754 other => {
1755 let s = other.to_string();
1756 format!("\"{s}\"")
1757 }
1758 }
1759}
1760
1761fn resolve_visitor_trait(module: &str) -> String {
1767 if module.contains("html_to_markdown") {
1769 "HtmlVisitor".to_string()
1770 } else {
1771 "Visitor".to_string()
1773 }
1774}
1775
1776fn emit_rust_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1784 let params = match method_name {
1788 "visit_link" => "_: &NodeContext, _: &str, _: &str, _: &str",
1789 "visit_image" => "_: &NodeContext, _: &str, _: &str, _: &str",
1790 "visit_heading" => "_: &NodeContext, _: u8, _: &str, _: Option<&str>",
1791 "visit_code_block" => "_: &NodeContext, _: Option<&str>, _: &str",
1792 "visit_code_inline"
1793 | "visit_strong"
1794 | "visit_emphasis"
1795 | "visit_strikethrough"
1796 | "visit_underline"
1797 | "visit_subscript"
1798 | "visit_superscript"
1799 | "visit_mark"
1800 | "visit_button"
1801 | "visit_summary"
1802 | "visit_figcaption"
1803 | "visit_definition_term"
1804 | "visit_definition_description" => "_: &NodeContext, _: &str",
1805 "visit_text" => "_: &NodeContext, _: &str",
1806 "visit_list_item" => "_: &NodeContext, _: bool, _: &str, _: &str",
1807 "visit_blockquote" => "_: &NodeContext, _: &str, _: u32",
1808 "visit_table_row" => "_: &NodeContext, _: &[String], _: bool",
1809 "visit_custom_element" => "_: &NodeContext, _: &str, _: &str",
1810 "visit_form" => "_: &NodeContext, _: &str, _: &str",
1811 "visit_input" => "_: &NodeContext, _: &str, _: &str, _: &str",
1812 "visit_audio" | "visit_video" | "visit_iframe" => "_: &NodeContext, _: &str",
1813 "visit_details" => "_: &NodeContext, _: bool",
1814 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1815 "_: &NodeContext, _: &str"
1816 }
1817 "visit_list_start" => "_: &NodeContext, _: bool",
1818 "visit_list_end" => "_: &NodeContext, _: bool, _: &str",
1819 _ => "_: &NodeContext",
1820 };
1821
1822 let _ = writeln!(out, " fn {method_name}(&mut self, {params}) -> VisitResult {{");
1823 match action {
1824 CallbackAction::Skip => {
1825 let _ = writeln!(out, " VisitResult::Skip");
1826 }
1827 CallbackAction::Continue => {
1828 let _ = writeln!(out, " VisitResult::Continue");
1829 }
1830 CallbackAction::PreserveHtml => {
1831 let _ = writeln!(out, " VisitResult::PreserveHtml");
1832 }
1833 CallbackAction::Custom { output } => {
1834 let escaped = escape_rust(output);
1835 let _ = writeln!(out, " VisitResult::Custom(\"{escaped}\".to_string())");
1836 }
1837 CallbackAction::CustomTemplate { template } => {
1838 let escaped = escape_rust(template);
1839 let _ = writeln!(out, " VisitResult::Custom(format!(\"{escaped}\"))");
1840 }
1841 }
1842 let _ = writeln!(out, " }}");
1843}