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 {
317 hash::header(CommentStyle::DoubleSlash)
318 + r#"//
319// Standalone mock HTTP server binary for cross-language e2e tests.
320// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
321//
322// Usage: mock-server [fixtures-dir]
323// fixtures-dir defaults to "../../fixtures"
324//
325// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
326// then optionally `MOCK_SERVERS={...}` with per-fixture URLs for host-root fixtures,
327// then blocks until stdin is closed (parent process exit triggers cleanup).
328
329use std::collections::HashMap;
330use std::io::{self, BufRead};
331use std::net::SocketAddr;
332use std::path::Path;
333use std::sync::Arc;
334
335use axum::Router;
336use axum::body::Body;
337use axum::extract::State;
338use axum::http::{Request, StatusCode};
339use axum::response::{IntoResponse, Response};
340use serde::Deserialize;
341use tokio::net::TcpListener;
342
343// ---------------------------------------------------------------------------
344// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
345// Supports both schemas:
346// liter-llm: mock_response: { status, body, stream_chunks }
347// spikard: http.expected_response: { status_code, body, headers }
348// ---------------------------------------------------------------------------
349
350#[derive(Debug, Deserialize)]
351struct MockResponse {
352 status: u16,
353 #[serde(default)]
354 body: Option<serde_json::Value>,
355 #[serde(default)]
356 stream_chunks: Option<Vec<serde_json::Value>>,
357 #[serde(default)]
358 headers: HashMap<String, String>,
359}
360
361#[derive(Debug, Deserialize)]
362struct HttpExpectedResponse {
363 status_code: u16,
364 #[serde(default)]
365 body: Option<serde_json::Value>,
366 #[serde(default)]
367 headers: HashMap<String, String>,
368}
369
370#[derive(Debug, Deserialize)]
371struct HttpFixture {
372 expected_response: HttpExpectedResponse,
373}
374
375#[derive(Debug, Deserialize)]
376struct Fixture {
377 id: String,
378 #[serde(default)]
379 mock_response: Option<MockResponse>,
380 #[serde(default)]
381 http: Option<HttpFixture>,
382 /// Array-form fixture schema. `input.mock_responses[i] = { path?, status_code, headers, body_inline | body_file }`.
383 /// Used by kreuzcrawl-style fixtures that mock multiple URLs per fixture (e.g. a page +
384 /// `/robots.txt` + `/sitemap.xml`).
385 #[serde(default)]
386 input: Option<serde_json::Value>,
387}
388
389/// A single resolved mock response with its serving path and whether it is a host-root path.
390struct ResolvedRoute {
391 /// The namespaced path under which this route is registered in the shared server,
392 /// e.g. `/fixtures/robots_disallow_path` or `/fixtures/robots_disallow_path/assets/style.css`.
393 path: String,
394 /// The original fixture-declared path (before namespacing), e.g. `/robots.txt` or `/assets/style.css`.
395 original_path: String,
396 response: MockResponse,
397 /// Body bytes (pre-loaded from body_file or body_inline).
398 body_bytes: Vec<u8>,
399}
400
401/// Returns true for paths that the crawler fetches from the host root rather than
402/// under a fixture-namespaced prefix. These require a dedicated per-fixture listener.
403fn is_host_root_path(path: &str) -> bool {
404 path.starts_with("/robots") || path.starts_with("/sitemap")
405}
406
407impl Fixture {
408 /// Bridge both schemas into a unified MockResponse.
409 fn as_mock_response(&self) -> Option<MockResponse> {
410 if let Some(mock) = &self.mock_response {
411 return Some(MockResponse {
412 status: mock.status,
413 body: mock.body.clone(),
414 stream_chunks: mock.stream_chunks.clone(),
415 headers: mock.headers.clone(),
416 });
417 }
418 if let Some(http) = &self.http {
419 return Some(MockResponse {
420 status: http.expected_response.status_code,
421 body: http.expected_response.body.clone(),
422 stream_chunks: None,
423 headers: http.expected_response.headers.clone(),
424 });
425 }
426 None
427 }
428
429 /// Resolve every mock response this fixture defines.
430 ///
431 /// Returns single-element output for the legacy `mock_response` / `http` schemas, and one
432 /// element per array entry for the kreuzcrawl-style `input.mock_responses` schema. For the
433 /// array schema, each element may declare its own `path` (defaulting to `/fixtures/{id}`),
434 /// and the body source can be either `body_inline` (string) or `body_file` (path relative
435 /// to the fixtures dir, loaded at startup).
436 ///
437 /// Paths that are NOT host-root paths (e.g. `/robots.txt`, `/sitemap.xml`) are namespaced
438 /// under `/fixtures/{id}` so that fixtures sharing common paths (like `/`, `/a`, `/b`) do
439 /// not collide in the shared route table.
440 fn as_routes(&self, fixtures_dir: &Path) -> Vec<ResolvedRoute> {
441 let mut routes = Vec::new();
442 let default_path = format!("/fixtures/{}", self.id);
443
444 if let Some(mock) = self.as_mock_response() {
445 let body_bytes = mock
446 .body
447 .as_ref()
448 .map(|b| match b {
449 serde_json::Value::String(s) => s.as_bytes().to_vec(),
450 other => serde_json::to_string(other).unwrap_or_default().into_bytes(),
451 })
452 .unwrap_or_default();
453 routes.push(ResolvedRoute {
454 path: default_path.clone(),
455 original_path: default_path.clone(),
456 response: mock,
457 body_bytes,
458 });
459 }
460
461 if let Some(input) = &self.input {
462 if let Some(arr) = input.get("mock_responses").and_then(|v| v.as_array()) {
463 for entry in arr {
464 let original_path = entry
465 .get("path")
466 .and_then(|v| v.as_str())
467 .unwrap_or("/")
468 .to_string();
469
470 // Namespace under /fixtures/<id> unless this is a host-root path.
471 let namespaced_path = if is_host_root_path(&original_path) {
472 original_path.clone()
473 } else if original_path == "/" {
474 format!("/fixtures/{}", self.id)
475 } else {
476 format!("/fixtures/{}{}", self.id, original_path)
477 };
478
479 let status: u16 = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
480 let headers: HashMap<String, String> = entry
481 .get("headers")
482 .and_then(|v| v.as_object())
483 .map(|h| {
484 h.iter()
485 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
486 .collect()
487 })
488 .unwrap_or_default();
489
490 // Load body bytes — use read() not read_to_string() to support binary files.
491 let body_bytes: Vec<u8> = if let Some(inline) = entry.get("body_inline").and_then(|v| v.as_str()) {
492 inline.as_bytes().to_vec()
493 } else if let Some(file) = entry.get("body_file").and_then(|v| v.as_str()) {
494 // body_file is resolved relative to `<fixtures>/responses/` first,
495 // falling back to `<fixtures>/` for projects that store body assets at
496 // the fixtures root rather than under a `responses/` subdir.
497 let candidates = [fixtures_dir.join("responses").join(file), fixtures_dir.join(file)];
498 let mut loaded = None;
499 for abs in &candidates {
500 if let Ok(bytes) = std::fs::read(abs) {
501 loaded = Some(bytes);
502 break;
503 }
504 }
505 match loaded {
506 Some(b) => b,
507 None => {
508 eprintln!(
509 "warning: cannot read body_file {} (tried {} and {})",
510 file,
511 candidates[0].display(),
512 candidates[1].display()
513 );
514 Vec::new()
515 }
516 }
517 } else {
518 Vec::new()
519 };
520
521 routes.push(ResolvedRoute {
522 path: namespaced_path,
523 original_path,
524 response: MockResponse {
525 status,
526 body: None,
527 stream_chunks: None,
528 headers,
529 },
530 body_bytes,
531 });
532 }
533 }
534 }
535
536 routes
537 }
538}
539
540// ---------------------------------------------------------------------------
541// Route table
542// ---------------------------------------------------------------------------
543
544#[derive(Clone, Debug)]
545struct MockRoute {
546 status: u16,
547 body: Vec<u8>,
548 stream_chunks: Vec<String>,
549 headers: Vec<(String, String)>,
550}
551
552type RouteTable = Arc<HashMap<String, MockRoute>>;
553
554// ---------------------------------------------------------------------------
555// Axum handler
556// ---------------------------------------------------------------------------
557
558async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
559 let path = req.uri().path().to_owned();
560
561 // Try exact match first
562 if let Some(route) = routes.get(&path) {
563 return serve_route(route);
564 }
565
566 // Try prefix match: find a route that is a prefix of the request path
567 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
568 for (route_path, route) in routes.iter() {
569 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
570 return serve_route(route);
571 }
572 }
573
574 Response::builder()
575 .status(StatusCode::NOT_FOUND)
576 .body(Body::from(format!("No mock route for {path}")))
577 .unwrap()
578 .into_response()
579}
580
581fn serve_route(route: &MockRoute) -> Response {
582 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
583
584 if !route.stream_chunks.is_empty() {
585 let mut sse = String::new();
586 for chunk in &route.stream_chunks {
587 sse.push_str("data: ");
588 sse.push_str(chunk);
589 sse.push_str("\n\n");
590 }
591 sse.push_str("data: [DONE]\n\n");
592
593 let mut builder = Response::builder()
594 .status(status)
595 .header("content-type", "text/event-stream")
596 .header("cache-control", "no-cache");
597 for (name, value) in &route.headers {
598 builder = builder.header(name, value);
599 }
600 return builder.body(Body::from(sse)).unwrap().into_response();
601 }
602
603 // Only set the default content-type if the fixture does not override it.
604 // Inspect the first non-whitespace byte to detect JSON vs binary vs plain text.
605 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
606 let mut builder = Response::builder().status(status);
607 if !has_content_type {
608 let first_nonws = route.body.iter().find(|&&b| b != b' ' && b != b'\t' && b != b'\n' && b != b'\r');
609 let default_ct = match first_nonws {
610 Some(&b'{') | Some(&b'[') => "application/json",
611 _ => "text/plain",
612 };
613 builder = builder.header("content-type", default_ct);
614 }
615 for (name, value) in &route.headers {
616 // Skip content-encoding headers — the mock server returns uncompressed bodies.
617 // Sending a content-encoding without actually encoding the body would cause
618 // clients to fail decompression.
619 if name.to_lowercase() == "content-encoding" {
620 continue;
621 }
622 // The <<absent>> sentinel means this header must NOT be present in the
623 // real server response — do not emit it from the mock server either.
624 if value == "<<absent>>" {
625 continue;
626 }
627 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
628 // assert the header value matches the UUID pattern.
629 if value == "<<uuid>>" {
630 let uuid = format!(
631 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
632 rand_u32(),
633 rand_u16(),
634 rand_u16() & 0x0fff,
635 (rand_u16() & 0x3fff) | 0x8000,
636 rand_u48(),
637 );
638 builder = builder.header(name, uuid);
639 continue;
640 }
641 builder = builder.header(name, value);
642 }
643 builder.body(Body::from(route.body.clone())).unwrap().into_response()
644}
645
646/// Generate a pseudo-random u32 using the current time nanoseconds.
647fn rand_u32() -> u32 {
648 use std::time::{SystemTime, UNIX_EPOCH};
649 let ns = SystemTime::now()
650 .duration_since(UNIX_EPOCH)
651 .map(|d| d.subsec_nanos())
652 .unwrap_or(0);
653 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
654}
655
656fn rand_u16() -> u16 {
657 (rand_u32() & 0xffff) as u16
658}
659
660fn rand_u48() -> u64 {
661 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
662}
663
664// ---------------------------------------------------------------------------
665// Fixture loading
666// ---------------------------------------------------------------------------
667
668/// Intermediate fixture-loading result: shared route table plus per-fixture host-root data.
669struct LoadedRoutes {
670 /// Routes namespaced under /fixtures/<id> for the shared listener.
671 shared: HashMap<String, MockRoute>,
672 /// For each fixture that has host-root routes: fixture_id → route table at host root.
673 per_fixture: HashMap<String, HashMap<String, MockRoute>>,
674}
675
676fn load_routes(fixtures_dir: &Path) -> LoadedRoutes {
677 let mut shared = HashMap::new();
678 let mut per_fixture: HashMap<String, HashMap<String, MockRoute>> = HashMap::new();
679 load_routes_recursive(fixtures_dir, fixtures_dir, &mut shared, &mut per_fixture);
680 LoadedRoutes { shared, per_fixture }
681}
682
683fn load_routes_recursive(
684 dir: &Path,
685 fixtures_root: &Path,
686 shared: &mut HashMap<String, MockRoute>,
687 per_fixture: &mut HashMap<String, HashMap<String, MockRoute>>,
688) {
689 let entries = match std::fs::read_dir(dir) {
690 Ok(e) => e,
691 Err(err) => {
692 eprintln!("warning: cannot read directory {}: {err}", dir.display());
693 return;
694 }
695 };
696
697 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
698 paths.sort();
699
700 for path in paths {
701 if path.is_dir() {
702 load_routes_recursive(&path, fixtures_root, shared, per_fixture);
703 } else if path.extension().is_some_and(|ext| ext == "json") {
704 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
705 if filename == "schema.json" || filename.starts_with('_') {
706 continue;
707 }
708 let content = match std::fs::read_to_string(&path) {
709 Ok(c) => c,
710 Err(err) => {
711 eprintln!("warning: cannot read {}: {err}", path.display());
712 continue;
713 }
714 };
715 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
716 match serde_json::from_str(&content) {
717 Ok(v) => v,
718 Err(err) => {
719 eprintln!("warning: cannot parse {}: {err}", path.display());
720 continue;
721 }
722 }
723 } else {
724 match serde_json::from_str::<Fixture>(&content) {
725 Ok(f) => vec![f],
726 Err(err) => {
727 eprintln!("warning: cannot parse {}: {err}", path.display());
728 continue;
729 }
730 }
731 };
732
733 for fixture in fixtures {
734 let resolved_routes = fixture.as_routes(fixtures_root);
735 let has_host_root = resolved_routes.iter().any(|r| is_host_root_path(&r.original_path));
736
737 for resolved in resolved_routes {
738 let stream_chunks = resolved.response
739 .stream_chunks
740 .unwrap_or_default()
741 .into_iter()
742 .map(|c| match c {
743 serde_json::Value::String(s) => s,
744 other => serde_json::to_string(&other).unwrap_or_default(),
745 })
746 .collect();
747 let mut headers: Vec<(String, String)> = resolved.response.headers.into_iter().collect();
748 headers.sort_by(|a, b| a.0.cmp(&b.0));
749
750 let mock_route = MockRoute {
751 status: resolved.response.status,
752 body: resolved.body_bytes,
753 stream_chunks,
754 headers,
755 };
756
757 // Always insert into the shared namespaced table.
758 shared.insert(resolved.path.clone(), mock_route.clone());
759
760 // For fixtures with host-root routes, also build a per-fixture table
761 // where routes are mounted at their original (un-namespaced) paths.
762 if has_host_root {
763 per_fixture
764 .entry(fixture.id.clone())
765 .or_default()
766 .insert(resolved.original_path.clone(), mock_route);
767 }
768 }
769 }
770 }
771 }
772}
773
774// ---------------------------------------------------------------------------
775// Entry point
776// ---------------------------------------------------------------------------
777
778#[tokio::main]
779async fn main() {
780 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
781 let fixtures_dir = Path::new(&fixtures_dir_arg);
782
783 let loaded = load_routes(fixtures_dir);
784 eprintln!("mock-server: loaded {} shared routes from {}", loaded.shared.len(), fixtures_dir.display());
785
786 // Shared namespaced server.
787 let shared_table: RouteTable = Arc::new(loaded.shared);
788 let shared_app = Router::new().fallback(handle_request).with_state(shared_table);
789
790 let shared_listener = TcpListener::bind("127.0.0.1:0")
791 .await
792 .expect("mock-server: failed to bind shared port");
793 let shared_addr: SocketAddr = shared_listener.local_addr().expect("mock-server: failed to get shared local addr");
794
795 // Per-fixture listeners for host-root routes (robots.txt, sitemap.xml, etc.).
796 // Sorted by fixture_id for deterministic output.
797 let mut fixture_ids: Vec<String> = loaded.per_fixture.keys().cloned().collect();
798 fixture_ids.sort();
799
800 let mut fixture_urls: HashMap<String, String> = HashMap::new();
801 for fixture_id in &fixture_ids {
802 let routes = loaded.per_fixture[fixture_id].clone();
803 eprintln!("mock-server: fixture {} has {} host-root routes", fixture_id, routes.len());
804 let table: RouteTable = Arc::new(routes);
805 let app = Router::new().fallback(handle_request).with_state(table);
806 let listener = TcpListener::bind("127.0.0.1:0")
807 .await
808 .expect("mock-server: failed to bind per-fixture port");
809 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get per-fixture local addr");
810 fixture_urls.insert(fixture_id.clone(), format!("http://{addr}"));
811 tokio::spawn(async move {
812 axum::serve(listener, app).await.expect("mock-server: per-fixture server error");
813 });
814 }
815
816 // Print the shared URL so the parent process can read it.
817 println!("MOCK_SERVER_URL=http://{shared_addr}");
818
819 // Print per-fixture URLs as a sorted JSON object if any exist.
820 if !fixture_urls.is_empty() {
821 let mut sorted_pairs: Vec<(&String, &String)> = fixture_urls.iter().collect();
822 sorted_pairs.sort_by_key(|(k, _)| k.as_str());
823 let json_entries: Vec<String> = sorted_pairs
824 .iter()
825 .map(|(k, v)| format!("\"{}\":\"{}\"", k, v))
826 .collect();
827 println!("MOCK_SERVERS={{{}}}", json_entries.join(","));
828 }
829
830 // Flush stdout explicitly so the parent does not block waiting.
831 use std::io::Write;
832 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
833
834 // Spawn the shared server in the background.
835 tokio::spawn(async move {
836 axum::serve(shared_listener, shared_app).await.expect("mock-server: shared server error");
837 });
838
839 // Block until stdin is closed — the parent process controls lifetime.
840 let stdin = io::stdin();
841 let mut lines = stdin.lock().lines();
842 while lines.next().is_some() {}
843}
844"#
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850
851 #[test]
852 fn render_mock_server_module_contains_struct_definition() {
853 let out = render_mock_server_module();
854 assert!(out.contains("pub struct MockRoute"));
855 assert!(out.contains("pub struct MockServer"));
856 }
857
858 #[test]
859 fn render_mock_server_binary_contains_main() {
860 let out = render_mock_server_binary();
861 assert!(out.contains("async fn main()"));
862 assert!(out.contains("MOCK_SERVER_URL=http://"));
863 }
864}