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