1use std::fmt::Write as FmtWrite;
4
5use alef_core::hash::{self, CommentStyle};
6
7use crate::config::E2eConfig;
8use crate::escape::rust_raw_string;
9use crate::fixture::Fixture;
10
11pub fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
17 let mut routes = Vec::new();
19
20 if let Some(mock_responses) = fixture.input.get("mock_responses").and_then(|v| v.as_array()) {
21 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
23 let default_path = call_config.path.as_deref().unwrap_or("/");
24 let default_method = call_config.method.as_deref().unwrap_or("POST");
25
26 for response in mock_responses {
27 if let Ok(obj) = serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(response.clone()) {
28 let path = obj
29 .get("path")
30 .and_then(|v| v.as_str())
31 .unwrap_or(default_path)
32 .to_string();
33 let method = obj
34 .get("method")
35 .and_then(|v| v.as_str())
36 .unwrap_or(default_method)
37 .to_string();
38 let status: u16 = obj.get("status_code").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
39
40 let headers: Vec<(String, String)> = obj
41 .get("headers")
42 .and_then(|v| v.as_object())
43 .map(|h| {
44 let mut entries: Vec<_> = h
45 .iter()
46 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
47 .collect();
48 entries.sort_by(|a, b| a.0.cmp(&b.0));
49 entries
50 })
51 .unwrap_or_default();
52
53 let body_str = if let Some(body_inline) = obj.get("body_inline").and_then(|v| v.as_str()) {
54 rust_raw_string(body_inline)
55 } else {
56 rust_raw_string("{}")
59 };
60
61 routes.push((path, method, status, body_str, headers));
62 }
63 }
64 } else if let Some(mock) = fixture.mock_response.as_ref() {
65 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
67 let path = call_config.path.as_deref().unwrap_or("/");
68 let method = call_config.method.as_deref().unwrap_or("POST");
69
70 let status = mock.status;
71
72 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
74 header_entries.sort_by(|a, b| a.0.cmp(b.0));
75 let header_tuples: Vec<(String, String)> = header_entries
76 .into_iter()
77 .map(|(k, v)| (k.clone(), v.clone()))
78 .collect();
79
80 let body_str = match &mock.body {
81 Some(b) => {
82 let s = serde_json::to_string(b).unwrap_or_default();
83 rust_raw_string(&s)
84 }
85 None => rust_raw_string("{}"),
86 };
87
88 if let Some(chunks) = &mock.stream_chunks {
90 let _ = writeln!(out, " let mock_route = MockRoute {{");
92 let _ = writeln!(out, " path: \"{path}\",");
93 let _ = writeln!(out, " method: \"{method}\",");
94 let _ = writeln!(out, " status: {status},");
95 let _ = writeln!(out, " body: String::new(),");
96 let _ = writeln!(out, " stream_chunks: vec![");
97 for chunk in chunks {
98 let chunk_str = match chunk {
99 serde_json::Value::String(s) => rust_raw_string(s),
100 other => {
101 let s = serde_json::to_string(other).unwrap_or_default();
102 rust_raw_string(&s)
103 }
104 };
105 let _ = writeln!(out, " {chunk_str}.to_string(),");
106 }
107 let _ = writeln!(out, " ],");
108 let _ = writeln!(out, " headers: vec![");
109 for (name, value) in &header_tuples {
110 let n = rust_raw_string(name);
111 let v = rust_raw_string(value);
112 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
113 }
114 let _ = writeln!(out, " ],");
115 let _ = writeln!(out, " }};");
116 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
117 return;
118 }
119
120 routes.push((path.to_string(), method.to_string(), status, body_str, header_tuples));
121 } else {
122 return;
123 }
124
125 if routes.len() == 1 {
127 let (path, method, status, body_str, header_entries) = routes.pop().unwrap();
128 let _ = writeln!(out, " let mock_route = MockRoute {{");
129 let _ = writeln!(out, " path: \"{path}\",");
130 let _ = writeln!(out, " method: \"{method}\",");
131 let _ = writeln!(out, " status: {status},");
132 let _ = writeln!(out, " body: {body_str}.to_string(),");
133 let _ = writeln!(out, " stream_chunks: vec![],");
134 let _ = writeln!(out, " headers: vec![");
135 for (name, value) in &header_entries {
136 let n = rust_raw_string(name);
137 let v = rust_raw_string(value);
138 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
139 }
140 let _ = writeln!(out, " ],");
141 let _ = writeln!(out, " }};");
142 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
143 } else {
144 let _ = writeln!(out, " let mut mock_routes = vec![];");
146 for (path, method, status, body_str, header_entries) in routes {
147 let _ = writeln!(out, " mock_routes.push(MockRoute {{");
148 let _ = writeln!(out, " path: \"{path}\",");
149 let _ = writeln!(out, " method: \"{method}\",");
150 let _ = writeln!(out, " status: {status},");
151 let _ = writeln!(out, " body: {body_str}.to_string(),");
152 let _ = writeln!(out, " stream_chunks: vec![],");
153 let _ = writeln!(out, " headers: vec![");
154 for (name, value) in &header_entries {
155 let n = rust_raw_string(name);
156 let v = rust_raw_string(value);
157 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
158 }
159 let _ = writeln!(out, " ],");
160 let _ = writeln!(out, " }});");
161 }
162 let _ = writeln!(out, " let mock_server = MockServer::start(mock_routes).await;");
163 }
164}
165
166pub fn render_mock_server_module() -> String {
168 hash::header(CommentStyle::DoubleSlash)
171 + r#"//
172// Minimal axum-based mock HTTP server for e2e tests.
173
174use std::net::SocketAddr;
175use std::sync::Arc;
176
177use axum::Router;
178use axum::body::Body;
179use axum::extract::State;
180use axum::http::{Request, StatusCode};
181use axum::response::{IntoResponse, Response};
182use tokio::net::TcpListener;
183
184/// A single mock route: match by path + method, return a configured response.
185#[derive(Clone, Debug)]
186pub struct MockRoute {
187 /// URL path to match, e.g. `"/v1/chat/completions"`.
188 pub path: &'static str,
189 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
190 pub method: &'static str,
191 /// HTTP status code to return.
192 pub status: u16,
193 /// Response body JSON string (used when `stream_chunks` is empty).
194 pub body: String,
195 /// Ordered SSE data payloads for streaming responses.
196 /// Each entry becomes `data: <chunk>\n\n` in the response.
197 /// A final `data: [DONE]\n\n` is always appended.
198 pub stream_chunks: Vec<String>,
199 /// Response headers to apply (name, value) pairs.
200 /// Multiple entries with the same name produce multiple header lines.
201 pub headers: Vec<(String, String)>,
202}
203
204struct ServerState {
205 routes: Vec<MockRoute>,
206}
207
208pub struct MockServer {
209 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
210 pub url: String,
211 handle: tokio::task::JoinHandle<()>,
212}
213
214impl MockServer {
215 /// Start a mock server with the given routes. Binds to a random port on
216 /// localhost and returns immediately once the server is listening.
217 pub async fn start(routes: Vec<MockRoute>) -> Self {
218 let state = Arc::new(ServerState { routes });
219
220 let app = Router::new().fallback(handle_request).with_state(state);
221
222 let listener = TcpListener::bind("127.0.0.1:0")
223 .await
224 .expect("Failed to bind mock server port");
225 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
226 let url = format!("http://{addr}");
227
228 let handle = tokio::spawn(async move {
229 axum::serve(listener, app).await.expect("Mock server failed");
230 });
231
232 MockServer { url, handle }
233 }
234
235 /// Stop the mock server.
236 pub fn shutdown(self) {
237 self.handle.abort();
238 }
239}
240
241impl Drop for MockServer {
242 fn drop(&mut self) {
243 self.handle.abort();
244 }
245}
246
247async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
248 let path = req.uri().path().to_owned();
249 let method = req.method().as_str().to_uppercase();
250
251 for route in &state.routes {
252 // Match on method and either exact path or path prefix (route.path is a prefix of the
253 // request path, separated by a '/' boundary). This allows a single route registered at
254 // "/v1/batches" to match requests to "/v1/batches/abc123" or
255 // "/v1/batches/abc123/cancel".
256 let path_matches = path == route.path
257 || (path.starts_with(route.path)
258 && path.as_bytes().get(route.path.len()) == Some(&b'/'));
259 if path_matches && route.method.to_uppercase() == method {
260 let status =
261 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
262
263 if !route.stream_chunks.is_empty() {
264 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
265 let mut sse = String::new();
266 for chunk in &route.stream_chunks {
267 sse.push_str("data: ");
268 sse.push_str(chunk);
269 sse.push_str("\n\n");
270 }
271 sse.push_str("data: [DONE]\n\n");
272
273 let mut builder = Response::builder()
274 .status(status)
275 .header("content-type", "text/event-stream")
276 .header("cache-control", "no-cache");
277 for (name, value) in &route.headers {
278 builder = builder.header(name, value);
279 }
280 return builder.body(Body::from(sse)).unwrap().into_response();
281 }
282
283 let mut builder =
284 Response::builder().status(status).header("content-type", "application/json");
285 for (name, value) in &route.headers {
286 builder = builder.header(name, value);
287 }
288 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
289 }
290 }
291
292 // No matching route → 404.
293 Response::builder()
294 .status(StatusCode::NOT_FOUND)
295 .body(Body::from(format!("No mock route for {method} {path}")))
296 .unwrap()
297 .into_response()
298}
299"#
300}
301
302pub fn render_mock_server_binary() -> String {
314 hash::header(CommentStyle::DoubleSlash)
315 + r#"//
316// Standalone mock HTTP server binary for cross-language e2e tests.
317// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
318//
319// Usage: mock-server [fixtures-dir]
320// fixtures-dir defaults to "../../fixtures"
321//
322// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
323// then blocks until stdin is closed (parent process exit triggers cleanup).
324
325use std::collections::HashMap;
326use std::io::{self, BufRead};
327use std::net::SocketAddr;
328use std::path::Path;
329use std::sync::Arc;
330
331use axum::Router;
332use axum::body::Body;
333use axum::extract::State;
334use axum::http::{Request, StatusCode};
335use axum::response::{IntoResponse, Response};
336use serde::Deserialize;
337use tokio::net::TcpListener;
338
339// ---------------------------------------------------------------------------
340// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
341// Supports both schemas:
342// liter-llm: mock_response: { status, body, stream_chunks }
343// spikard: http.expected_response: { status_code, body, headers }
344// ---------------------------------------------------------------------------
345
346#[derive(Debug, Deserialize)]
347struct MockResponse {
348 status: u16,
349 #[serde(default)]
350 body: Option<serde_json::Value>,
351 #[serde(default)]
352 stream_chunks: Option<Vec<serde_json::Value>>,
353 #[serde(default)]
354 headers: HashMap<String, String>,
355}
356
357#[derive(Debug, Deserialize)]
358struct HttpExpectedResponse {
359 status_code: u16,
360 #[serde(default)]
361 body: Option<serde_json::Value>,
362 #[serde(default)]
363 headers: HashMap<String, String>,
364}
365
366#[derive(Debug, Deserialize)]
367struct HttpFixture {
368 expected_response: HttpExpectedResponse,
369}
370
371#[derive(Debug, Deserialize)]
372struct Fixture {
373 id: String,
374 #[serde(default)]
375 mock_response: Option<MockResponse>,
376 #[serde(default)]
377 http: Option<HttpFixture>,
378 /// Array-form fixture schema. `input.mock_responses[i] = { path?, status_code, headers, body_inline | body_file }`.
379 /// Used by kreuzcrawl-style fixtures that mock multiple URLs per fixture (e.g. a page +
380 /// `/robots.txt` + `/sitemap.xml`).
381 #[serde(default)]
382 input: Option<serde_json::Value>,
383}
384
385/// A single resolved mock response with its serving path.
386struct ResolvedRoute {
387 path: String,
388 response: MockResponse,
389}
390
391impl Fixture {
392 /// Bridge both schemas into a unified MockResponse.
393 fn as_mock_response(&self) -> Option<MockResponse> {
394 if let Some(mock) = &self.mock_response {
395 return Some(MockResponse {
396 status: mock.status,
397 body: mock.body.clone(),
398 stream_chunks: mock.stream_chunks.clone(),
399 headers: mock.headers.clone(),
400 });
401 }
402 if let Some(http) = &self.http {
403 return Some(MockResponse {
404 status: http.expected_response.status_code,
405 body: http.expected_response.body.clone(),
406 stream_chunks: None,
407 headers: http.expected_response.headers.clone(),
408 });
409 }
410 None
411 }
412
413 /// Resolve every mock response this fixture defines.
414 ///
415 /// Returns single-element output for the legacy `mock_response` / `http` schemas, and one
416 /// element per array entry for the kreuzcrawl-style `input.mock_responses` schema. For the
417 /// array schema, each element may declare its own `path` (defaulting to `/fixtures/{id}`),
418 /// and the body source can be either `body_inline` (string) or `body_file` (path relative
419 /// to the fixtures dir, loaded at startup).
420 fn as_routes(&self, fixtures_dir: &Path) -> Vec<ResolvedRoute> {
421 let mut routes = Vec::new();
422 let default_path = format!("/fixtures/{}", self.id);
423
424 if let Some(mock) = self.as_mock_response() {
425 routes.push(ResolvedRoute {
426 path: default_path.clone(),
427 response: mock,
428 });
429 }
430
431 if let Some(input) = &self.input {
432 if let Some(arr) = input.get("mock_responses").and_then(|v| v.as_array()) {
433 for entry in arr {
434 let path = entry
435 .get("path")
436 .and_then(|v| v.as_str())
437 .map(|s| s.to_string())
438 .unwrap_or_else(|| default_path.clone());
439 let status: u16 = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
440 let headers: HashMap<String, String> = entry
441 .get("headers")
442 .and_then(|v| v.as_object())
443 .map(|h| {
444 h.iter()
445 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
446 .collect()
447 })
448 .unwrap_or_default();
449 let body: Option<serde_json::Value> = if let Some(inline) = entry.get("body_inline") {
450 Some(inline.clone())
451 } else if let Some(file) = entry.get("body_file").and_then(|v| v.as_str()) {
452 // body_file is resolved relative to `<fixtures>/responses/` first,
453 // falling back to `<fixtures>/` for projects that store body assets at
454 // the fixtures root rather than under a `responses/` subdir.
455 let candidates = [fixtures_dir.join("responses").join(file), fixtures_dir.join(file)];
456 let mut loaded = None;
457 for abs in &candidates {
458 if let Ok(s) = std::fs::read_to_string(abs) {
459 loaded = Some(s);
460 break;
461 }
462 }
463 match loaded {
464 Some(s) => Some(serde_json::Value::String(s)),
465 None => {
466 eprintln!(
467 "warning: cannot read body_file {} (tried {} and {})",
468 file,
469 candidates[0].display(),
470 candidates[1].display()
471 );
472 None
473 }
474 }
475 } else {
476 None
477 };
478 routes.push(ResolvedRoute {
479 path,
480 response: MockResponse {
481 status,
482 body,
483 stream_chunks: None,
484 headers,
485 },
486 });
487 }
488 }
489 }
490
491 routes
492 }
493}
494
495// ---------------------------------------------------------------------------
496// Route table
497// ---------------------------------------------------------------------------
498
499#[derive(Clone, Debug)]
500struct MockRoute {
501 status: u16,
502 body: String,
503 stream_chunks: Vec<String>,
504 headers: Vec<(String, String)>,
505}
506
507type RouteTable = Arc<HashMap<String, MockRoute>>;
508
509// ---------------------------------------------------------------------------
510// Axum handler
511// ---------------------------------------------------------------------------
512
513async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
514 let path = req.uri().path().to_owned();
515
516 // Try exact match first
517 if let Some(route) = routes.get(&path) {
518 return serve_route(route);
519 }
520
521 // Try prefix match: find a route that is a prefix of the request path
522 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
523 for (route_path, route) in routes.iter() {
524 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
525 return serve_route(route);
526 }
527 }
528
529 Response::builder()
530 .status(StatusCode::NOT_FOUND)
531 .body(Body::from(format!("No mock route for {path}")))
532 .unwrap()
533 .into_response()
534}
535
536fn serve_route(route: &MockRoute) -> Response {
537 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
538
539 if !route.stream_chunks.is_empty() {
540 let mut sse = String::new();
541 for chunk in &route.stream_chunks {
542 sse.push_str("data: ");
543 sse.push_str(chunk);
544 sse.push_str("\n\n");
545 }
546 sse.push_str("data: [DONE]\n\n");
547
548 let mut builder = Response::builder()
549 .status(status)
550 .header("content-type", "text/event-stream")
551 .header("cache-control", "no-cache");
552 for (name, value) in &route.headers {
553 builder = builder.header(name, value);
554 }
555 return builder.body(Body::from(sse)).unwrap().into_response();
556 }
557
558 // Only set the default content-type if the fixture does not override it.
559 // Use application/json when the body looks like JSON (starts with { or [),
560 // otherwise fall back to text/plain to avoid clients failing JSON-decode.
561 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
562 let mut builder = Response::builder().status(status);
563 if !has_content_type {
564 let trimmed = route.body.trim_start();
565 let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
566 "application/json"
567 } else {
568 "text/plain"
569 };
570 builder = builder.header("content-type", default_ct);
571 }
572 for (name, value) in &route.headers {
573 // Skip content-encoding headers — the mock server returns uncompressed bodies.
574 // Sending a content-encoding without actually encoding the body would cause
575 // clients to fail decompression.
576 if name.to_lowercase() == "content-encoding" {
577 continue;
578 }
579 // The <<absent>> sentinel means this header must NOT be present in the
580 // real server response — do not emit it from the mock server either.
581 if value == "<<absent>>" {
582 continue;
583 }
584 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
585 // assert the header value matches the UUID pattern.
586 if value == "<<uuid>>" {
587 let uuid = format!(
588 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
589 rand_u32(),
590 rand_u16(),
591 rand_u16() & 0x0fff,
592 (rand_u16() & 0x3fff) | 0x8000,
593 rand_u48(),
594 );
595 builder = builder.header(name, uuid);
596 continue;
597 }
598 builder = builder.header(name, value);
599 }
600 builder.body(Body::from(route.body.clone())).unwrap().into_response()
601}
602
603/// Generate a pseudo-random u32 using the current time nanoseconds.
604fn rand_u32() -> u32 {
605 use std::time::{SystemTime, UNIX_EPOCH};
606 let ns = SystemTime::now()
607 .duration_since(UNIX_EPOCH)
608 .map(|d| d.subsec_nanos())
609 .unwrap_or(0);
610 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
611}
612
613fn rand_u16() -> u16 {
614 (rand_u32() & 0xffff) as u16
615}
616
617fn rand_u48() -> u64 {
618 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
619}
620
621// ---------------------------------------------------------------------------
622// Fixture loading
623// ---------------------------------------------------------------------------
624
625fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
626 let mut routes = HashMap::new();
627 load_routes_recursive(fixtures_dir, fixtures_dir, &mut routes);
628 routes
629}
630
631fn load_routes_recursive(dir: &Path, fixtures_root: &Path, routes: &mut HashMap<String, MockRoute>) {
632 let entries = match std::fs::read_dir(dir) {
633 Ok(e) => e,
634 Err(err) => {
635 eprintln!("warning: cannot read directory {}: {err}", dir.display());
636 return;
637 }
638 };
639
640 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
641 paths.sort();
642
643 for path in paths {
644 if path.is_dir() {
645 load_routes_recursive(&path, fixtures_root, routes);
646 } else if path.extension().is_some_and(|ext| ext == "json") {
647 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
648 if filename == "schema.json" || filename.starts_with('_') {
649 continue;
650 }
651 let content = match std::fs::read_to_string(&path) {
652 Ok(c) => c,
653 Err(err) => {
654 eprintln!("warning: cannot read {}: {err}", path.display());
655 continue;
656 }
657 };
658 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
659 match serde_json::from_str(&content) {
660 Ok(v) => v,
661 Err(err) => {
662 eprintln!("warning: cannot parse {}: {err}", path.display());
663 continue;
664 }
665 }
666 } else {
667 match serde_json::from_str::<Fixture>(&content) {
668 Ok(f) => vec![f],
669 Err(err) => {
670 eprintln!("warning: cannot parse {}: {err}", path.display());
671 continue;
672 }
673 }
674 };
675
676 for fixture in fixtures {
677 for resolved in fixture.as_routes(fixtures_root) {
678 let mock = resolved.response;
679 let body = mock
680 .body
681 .as_ref()
682 .map(|b| match b {
683 // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
684 // fixtures. Return the raw value so clients receive the string itself,
685 // not its JSON-encoded form with extra surrounding quotes.
686 serde_json::Value::String(s) => s.clone(),
687 other => serde_json::to_string(other).unwrap_or_default(),
688 })
689 .unwrap_or_default();
690 let stream_chunks = mock
691 .stream_chunks
692 .unwrap_or_default()
693 .into_iter()
694 .map(|c| match c {
695 serde_json::Value::String(s) => s,
696 other => serde_json::to_string(&other).unwrap_or_default(),
697 })
698 .collect();
699 let mut headers: Vec<(String, String)> = mock.headers.into_iter().collect();
700 headers.sort_by(|a, b| a.0.cmp(&b.0));
701 routes.insert(
702 resolved.path,
703 MockRoute {
704 status: mock.status,
705 body,
706 stream_chunks,
707 headers,
708 },
709 );
710 }
711 }
712 }
713 }
714}
715
716// ---------------------------------------------------------------------------
717// Entry point
718// ---------------------------------------------------------------------------
719
720#[tokio::main]
721async fn main() {
722 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
723 let fixtures_dir = Path::new(&fixtures_dir_arg);
724
725 let routes = load_routes(fixtures_dir);
726 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
727
728 let route_table: RouteTable = Arc::new(routes);
729 let app = Router::new().fallback(handle_request).with_state(route_table);
730
731 let listener = TcpListener::bind("127.0.0.1:0")
732 .await
733 .expect("mock-server: failed to bind port");
734 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
735
736 // Print the URL so the parent process can read it.
737 println!("MOCK_SERVER_URL=http://{addr}");
738 // Flush stdout explicitly so the parent does not block waiting.
739 use std::io::Write;
740 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
741
742 // Spawn the server in the background.
743 tokio::spawn(async move {
744 axum::serve(listener, app).await.expect("mock-server: server error");
745 });
746
747 // Block until stdin is closed — the parent process controls lifetime.
748 let stdin = io::stdin();
749 let mut lines = stdin.lock().lines();
750 while lines.next().is_some() {}
751}
752"#
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[test]
760 fn render_mock_server_module_contains_struct_definition() {
761 let out = render_mock_server_module();
762 assert!(out.contains("pub struct MockRoute"));
763 assert!(out.contains("pub struct MockServer"));
764 }
765
766 #[test]
767 fn render_mock_server_binary_contains_main() {
768 let out = render_mock_server_binary();
769 assert!(out.contains("async fn main()"));
770 assert!(out.contains("MOCK_SERVER_URL=http://"));
771 }
772}