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