Skip to main content

alef_e2e/codegen/rust/
mock_server.rs

1//! Mock server source generation for Rust e2e tests.
2
3use 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
11/// Emit mock server setup lines into a test function body.
12///
13/// Builds `MockRoute` objects from the fixture's `mock_response` (single-response schema)
14/// or `input.mock_responses` (array schema for multiple responses per fixture).
15/// The resulting `mock_server` variable is in scope for the rest of the test function.
16pub fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
17    // Try array schema first: input.mock_responses
18    let mut routes = Vec::new();
19
20    if let Some(mock_responses) = fixture.input.get("mock_responses").and_then(|v| v.as_array()) {
21        // Array schema: input.mock_responses[{ path, status_code, headers, body_inline, ... }]
22        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                    // Note: body_file support would require fixture-dir context at codegen time.
57                    // For now, we emit a placeholder; the standalone binary handles body_file.
58                    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        // Single-response schema: mock_response { status, body, stream_chunks, headers }
66        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        // Render headers map as a Vec<(String, String)> literal for stable iteration order.
73        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        // Handle streaming separately within the single-response case.
89        if let Some(chunks) = &mock.stream_chunks {
90            // Streaming SSE response.
91            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    // Emit all routes (array schema produces multiple; single schema produces one).
126    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        // Multiple routes from array schema.
145        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
166/// Generate the complete `mock_server.rs` module source.
167pub fn render_mock_server_module() -> String {
168    // This is parameterized Axum mock server code identical in structure to
169    // liter-llm's mock_server.rs but without any project-specific imports.
170    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
302/// Generate the `src/main.rs` for the standalone mock server binary.
303///
304/// The binary:
305/// - Reads all `*.json` fixture files from a fixtures directory (default `../../fixtures`).
306/// - For each fixture that has a `mock_response` field, registers a route at
307///   `/fixtures/{fixture_id}` returning the configured status/body/SSE chunks.
308/// - Binds to `127.0.0.1:0` (random port), prints `MOCK_SERVER_URL=http://...`
309///   to stdout, then waits until stdin is closed for clean teardown.
310/// - Fixtures that declare host-root paths (`/robots.txt`, `/sitemap.*`, etc.) get their
311///   own dedicated listener so the crawler can fetch them from the host root.  A second
312///   line `MOCK_SERVERS={...}` (sorted JSON object) maps fixture_id → base URL for those.
313///
314/// This binary is intended for cross-language e2e suites (WASM, Node) that
315/// spawn it as a child process and read the URL from its stdout.
316pub 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}