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                let delay_ms = obj.get("delay_ms").and_then(|v| v.as_u64());
62
63                routes.push((path, method, status, body_str, headers, delay_ms));
64            }
65        }
66    } else if let Some(mock) = fixture.mock_response.as_ref() {
67        // Single-response schema: mock_response { status, body, stream_chunks, headers }
68        let call_config = e2e_config.resolve_call(fixture.call.as_deref());
69        let path = call_config.path.as_deref().unwrap_or("/");
70        let method = call_config.method.as_deref().unwrap_or("POST");
71
72        let status = mock.status;
73
74        // Render headers map as a Vec<(String, String)> literal for stable iteration order.
75        let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
76        header_entries.sort_by(|a, b| a.0.cmp(b.0));
77        let header_tuples: Vec<(String, String)> = header_entries
78            .into_iter()
79            .map(|(k, v)| (k.clone(), v.clone()))
80            .collect();
81
82        let body_str = match &mock.body {
83            Some(b) => {
84                let s = serde_json::to_string(b).unwrap_or_default();
85                rust_raw_string(&s)
86            }
87            None => rust_raw_string("{}"),
88        };
89
90        // Handle streaming separately within the single-response case.
91        if let Some(chunks) = &mock.stream_chunks {
92            // Streaming SSE response.
93            let _ = writeln!(out, "    let mock_route = MockRoute {{");
94            let _ = writeln!(out, "        path: \"{path}\",");
95            let _ = writeln!(out, "        method: \"{method}\",");
96            let _ = writeln!(out, "        status: {status},");
97            let _ = writeln!(out, "        body: String::new(),");
98            let _ = writeln!(out, "        stream_chunks: vec![");
99            for chunk in chunks {
100                let chunk_str = match chunk {
101                    serde_json::Value::String(s) => rust_raw_string(s),
102                    other => {
103                        let s = serde_json::to_string(other).unwrap_or_default();
104                        rust_raw_string(&s)
105                    }
106                };
107                let _ = writeln!(out, "            {chunk_str}.to_string(),");
108            }
109            let _ = writeln!(out, "        ],");
110            let _ = writeln!(out, "        headers: vec![");
111            for (name, value) in &header_tuples {
112                let n = rust_raw_string(name);
113                let v = rust_raw_string(value);
114                let _ = writeln!(out, "            ({n}.to_string(), {v}.to_string()),");
115            }
116            let _ = writeln!(out, "        ],");
117            let _ = writeln!(out, "        delay_ms: None,");
118            let _ = writeln!(out, "    }};");
119            let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
120            return;
121        }
122
123        routes.push((
124            path.to_string(),
125            method.to_string(),
126            status,
127            body_str,
128            header_tuples,
129            None,
130        ));
131    } else {
132        return;
133    }
134
135    // Emit all routes (array schema produces multiple; single schema produces one).
136    if routes.len() == 1 {
137        let (path, method, status, body_str, header_entries, delay_ms) = routes.pop().unwrap();
138        let delay_literal = match delay_ms {
139            Some(ms) => format!("Some({ms})"),
140            None => "None".to_string(),
141        };
142        let _ = writeln!(out, "    let mock_route = MockRoute {{");
143        let _ = writeln!(out, "        path: \"{path}\",");
144        let _ = writeln!(out, "        method: \"{method}\",");
145        let _ = writeln!(out, "        status: {status},");
146        let _ = writeln!(out, "        body: {body_str}.to_string(),");
147        let _ = writeln!(out, "        stream_chunks: vec![],");
148        let _ = writeln!(out, "        headers: vec![");
149        for (name, value) in &header_entries {
150            let n = rust_raw_string(name);
151            let v = rust_raw_string(value);
152            let _ = writeln!(out, "            ({n}.to_string(), {v}.to_string()),");
153        }
154        let _ = writeln!(out, "        ],");
155        let _ = writeln!(out, "        delay_ms: {delay_literal},");
156        let _ = writeln!(out, "    }};");
157        let _ = writeln!(out, "    let mock_server = MockServer::start(vec![mock_route]).await;");
158    } else {
159        // Multiple routes from array schema.
160        let _ = writeln!(out, "    let mut mock_routes = vec![];");
161        for (path, method, status, body_str, header_entries, delay_ms) in routes {
162            let delay_literal = match delay_ms {
163                Some(ms) => format!("Some({ms})"),
164                None => "None".to_string(),
165            };
166            let _ = writeln!(out, "    mock_routes.push(MockRoute {{");
167            let _ = writeln!(out, "        path: \"{path}\",");
168            let _ = writeln!(out, "        method: \"{method}\",");
169            let _ = writeln!(out, "        status: {status},");
170            let _ = writeln!(out, "        body: {body_str}.to_string(),");
171            let _ = writeln!(out, "        stream_chunks: vec![],");
172            let _ = writeln!(out, "        headers: vec![");
173            for (name, value) in &header_entries {
174                let n = rust_raw_string(name);
175                let v = rust_raw_string(value);
176                let _ = writeln!(out, "            ({n}.to_string(), {v}.to_string()),");
177            }
178            let _ = writeln!(out, "        ],");
179            let _ = writeln!(out, "        delay_ms: {delay_literal},");
180            let _ = writeln!(out, "    }});");
181        }
182        let _ = writeln!(out, "    let mock_server = MockServer::start(mock_routes).await;");
183    }
184}
185
186/// Generate the complete `mock_server.rs` module source.
187pub fn render_mock_server_module() -> String {
188    // This is parameterized Axum mock server code identical in structure to
189    // liter-llm's mock_server.rs but without any project-specific imports.
190    hash::header(CommentStyle::DoubleSlash)
191        + r#"//
192// Minimal axum-based mock HTTP server for e2e tests.
193
194use std::net::SocketAddr;
195use std::sync::Arc;
196
197use axum::Router;
198use axum::body::Body;
199use axum::extract::State;
200use axum::http::{Request, StatusCode};
201use axum::response::{IntoResponse, Response};
202use tokio::net::TcpListener;
203
204/// A single mock route: match by path + method, return a configured response.
205#[derive(Clone, Debug)]
206pub struct MockRoute {
207    /// URL path to match, e.g. `"/v1/chat/completions"`.
208    pub path: &'static str,
209    /// HTTP method to match, e.g. `"POST"` or `"GET"`.
210    pub method: &'static str,
211    /// HTTP status code to return.
212    pub status: u16,
213    /// Response body JSON string (used when `stream_chunks` is empty).
214    pub body: String,
215    /// Ordered SSE data payloads for streaming responses.
216    /// Each entry becomes `data: <chunk>\n\n` in the response.
217    /// A final `data: [DONE]\n\n` is always appended.
218    pub stream_chunks: Vec<String>,
219    /// Response headers to apply (name, value) pairs.
220    /// Multiple entries with the same name produce multiple header lines.
221    pub headers: Vec<(String, String)>,
222    /// Optional artificial response delay in milliseconds. Used by timeout-error
223    /// fixtures to force a client-side request timeout.
224    pub delay_ms: Option<u64>,
225}
226
227struct ServerState {
228    routes: Vec<MockRoute>,
229}
230
231pub struct MockServer {
232    /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
233    pub url: String,
234    handle: tokio::task::JoinHandle<()>,
235}
236
237impl MockServer {
238    /// Start a mock server with the given routes.  Binds to a random port on
239    /// localhost and returns immediately once the server is listening.
240    pub async fn start(routes: Vec<MockRoute>) -> Self {
241        let state = Arc::new(ServerState { routes });
242
243        let app = Router::new().fallback(handle_request).with_state(state);
244
245        let listener = TcpListener::bind("127.0.0.1:0")
246            .await
247            .expect("Failed to bind mock server port");
248        let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
249        let url = format!("http://{addr}");
250
251        let handle = tokio::spawn(async move {
252            axum::serve(listener, app).await.expect("Mock server failed");
253        });
254
255        MockServer { url, handle }
256    }
257
258    /// Stop the mock server.
259    pub fn shutdown(self) {
260        self.handle.abort();
261    }
262}
263
264impl Drop for MockServer {
265    fn drop(&mut self) {
266        self.handle.abort();
267    }
268}
269
270async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
271    let path = req.uri().path().to_owned();
272    let method = req.method().as_str().to_uppercase();
273
274    for route in &state.routes {
275        // Match on method and either exact path or path prefix (route.path is a prefix of the
276        // request path, separated by a '/' boundary). This allows a single route registered at
277        // "/v1/batches" to match requests to "/v1/batches/abc123" or
278        // "/v1/batches/abc123/cancel".
279        let path_matches = path == route.path
280            || (path.starts_with(route.path)
281                && path.as_bytes().get(route.path.len()) == Some(&b'/'));
282        if path_matches && route.method.to_uppercase() == method {
283            if let Some(delay_ms) = route.delay_ms {
284                tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
285            }
286            let status =
287                StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
288
289            if !route.stream_chunks.is_empty() {
290                // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
291                let mut sse = String::new();
292                for chunk in &route.stream_chunks {
293                    sse.push_str("data: ");
294                    sse.push_str(chunk);
295                    sse.push_str("\n\n");
296                }
297                sse.push_str("data: [DONE]\n\n");
298
299                let mut builder = Response::builder()
300                    .status(status)
301                    .header("content-type", "text/event-stream")
302                    .header("cache-control", "no-cache");
303                for (name, value) in &route.headers {
304                    builder = builder.header(name, value);
305                }
306                return builder.body(Body::from(sse)).unwrap().into_response();
307            }
308
309            let mut builder =
310                Response::builder().status(status).header("content-type", "application/json");
311            for (name, value) in &route.headers {
312                builder = builder.header(name, value);
313            }
314            return builder.body(Body::from(route.body.clone())).unwrap().into_response();
315        }
316    }
317
318    // No matching route → 404.
319    Response::builder()
320        .status(StatusCode::NOT_FOUND)
321        .body(Body::from(format!("No mock route for {method} {path}")))
322        .unwrap()
323        .into_response()
324}
325"#
326}
327
328/// Generate the `src/main.rs` for the standalone mock server binary.
329///
330/// The binary:
331/// - Reads all `*.json` fixture files from a fixtures directory (default `../../fixtures`).
332/// - For each fixture that has a `mock_response` field, registers a route at
333///   `/fixtures/{fixture_id}` returning the configured status/body/SSE chunks.
334/// - Binds to `127.0.0.1:0` (random port), prints `MOCK_SERVER_URL=http://...`
335///   to stdout, then waits until stdin is closed for clean teardown.
336/// - Fixtures that declare host-root paths (`/robots.txt`, `/sitemap.*`, etc.) get their
337///   own dedicated listener so the crawler can fetch them from the host root.  A second
338///   line `MOCK_SERVERS={...}` (sorted JSON object) maps fixture_id → base URL for those.
339///
340/// This binary is intended for cross-language e2e suites (WASM, Node) that
341/// spawn it as a child process and read the URL from its stdout.
342pub fn render_mock_server_binary() -> String {
343    hash::header(CommentStyle::DoubleSlash)
344        + r#"//
345// Standalone mock HTTP server binary for cross-language e2e tests.
346// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
347//
348// Usage: mock-server [fixtures-dir]
349//   fixtures-dir defaults to "../../fixtures"
350//
351// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
352// then optionally `MOCK_SERVERS={...}` with per-fixture URLs for host-root fixtures,
353// then blocks until stdin is closed (parent process exit triggers cleanup).
354
355use std::collections::HashMap;
356use std::io::{self, BufRead};
357use std::net::SocketAddr;
358use std::path::Path;
359use std::sync::Arc;
360
361use axum::Router;
362use axum::body::Body;
363use axum::extract::State;
364use axum::http::{Request, StatusCode};
365use axum::response::{IntoResponse, Response};
366use serde::Deserialize;
367use tokio::net::TcpListener;
368
369// ---------------------------------------------------------------------------
370// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
371// Supports both schemas:
372//   liter-llm: mock_response: { status, body, stream_chunks }
373//   spikard:   http.expected_response: { status_code, body, headers }
374// ---------------------------------------------------------------------------
375
376#[derive(Debug, Deserialize)]
377struct MockResponse {
378    status: u16,
379    #[serde(default)]
380    body: Option<serde_json::Value>,
381    #[serde(default)]
382    stream_chunks: Option<Vec<serde_json::Value>>,
383    #[serde(default)]
384    headers: HashMap<String, String>,
385    /// Optional artificial delay (milliseconds) applied before sending the response.
386    /// Used by timeout-error fixtures to force the client request to time out.
387    #[serde(default)]
388    delay_ms: Option<u64>,
389}
390
391#[derive(Debug, Deserialize)]
392struct HttpExpectedResponse {
393    status_code: u16,
394    #[serde(default)]
395    body: Option<serde_json::Value>,
396    #[serde(default)]
397    headers: HashMap<String, String>,
398}
399
400#[derive(Debug, Deserialize)]
401struct HttpFixture {
402    expected_response: HttpExpectedResponse,
403}
404
405#[derive(Debug, Deserialize)]
406struct Fixture {
407    id: String,
408    #[serde(default)]
409    mock_response: Option<MockResponse>,
410    #[serde(default)]
411    http: Option<HttpFixture>,
412    /// Array-form fixture schema. `input.mock_responses[i] = { path?, status_code, headers, body_inline | body_file }`.
413    /// Used by kreuzcrawl-style fixtures that mock multiple URLs per fixture (e.g. a page +
414    /// `/robots.txt` + `/sitemap.xml`).
415    #[serde(default)]
416    input: Option<serde_json::Value>,
417}
418
419/// A single resolved mock response with its serving path and whether it is a host-root path.
420struct ResolvedRoute {
421    /// The namespaced path under which this route is registered in the shared server,
422    /// e.g. `/fixtures/robots_disallow_path` or `/fixtures/robots_disallow_path/assets/style.css`.
423    path: String,
424    /// The original fixture-declared path (before namespacing), e.g. `/robots.txt` or `/assets/style.css`.
425    original_path: String,
426    response: MockResponse,
427    /// Body bytes (pre-loaded from body_file or body_inline).
428    body_bytes: Vec<u8>,
429}
430
431/// Returns true for paths that the crawler fetches from the host root rather than
432/// under a fixture-namespaced prefix.  These require a dedicated per-fixture listener.
433fn is_host_root_path(path: &str) -> bool {
434    path.starts_with("/robots") || path.starts_with("/sitemap")
435}
436
437impl Fixture {
438    /// Bridge both schemas into a unified MockResponse.
439    fn as_mock_response(&self) -> Option<MockResponse> {
440        if let Some(mock) = &self.mock_response {
441            return Some(MockResponse {
442                status: mock.status,
443                body: mock.body.clone(),
444                stream_chunks: mock.stream_chunks.clone(),
445                headers: mock.headers.clone(),
446                delay_ms: mock.delay_ms,
447            });
448        }
449        if let Some(http) = &self.http {
450            return Some(MockResponse {
451                status: http.expected_response.status_code,
452                body: http.expected_response.body.clone(),
453                stream_chunks: None,
454                headers: http.expected_response.headers.clone(),
455                delay_ms: None,
456            });
457        }
458        None
459    }
460
461    /// Resolve every mock response this fixture defines.
462    ///
463    /// Returns single-element output for the legacy `mock_response` / `http` schemas, and one
464    /// element per array entry for the kreuzcrawl-style `input.mock_responses` schema. For the
465    /// array schema, each element may declare its own `path` (defaulting to `/fixtures/{id}`),
466    /// and the body source can be either `body_inline` (string) or `body_file` (path relative
467    /// to the fixtures dir, loaded at startup).
468    ///
469    /// Paths that are NOT host-root paths (e.g. `/robots.txt`, `/sitemap.xml`) are namespaced
470    /// under `/fixtures/{id}` so that fixtures sharing common paths (like `/`, `/a`, `/b`) do
471    /// not collide in the shared route table.
472    fn as_routes(&self, fixtures_dir: &Path) -> Vec<ResolvedRoute> {
473        let mut routes = Vec::new();
474        let default_path = format!("/fixtures/{}", self.id);
475
476        if let Some(mock) = self.as_mock_response() {
477            let body_bytes = mock
478                .body
479                .as_ref()
480                .map(|b| match b {
481                    serde_json::Value::String(s) => s.as_bytes().to_vec(),
482                    other => serde_json::to_string(other).unwrap_or_default().into_bytes(),
483                })
484                .unwrap_or_default();
485            routes.push(ResolvedRoute {
486                path: default_path.clone(),
487                original_path: default_path.clone(),
488                response: mock,
489                body_bytes,
490            });
491        }
492
493        if let Some(input) = &self.input {
494            if let Some(arr) = input.get("mock_responses").and_then(|v| v.as_array()) {
495                for entry in arr {
496                    let original_path = entry
497                        .get("path")
498                        .and_then(|v| v.as_str())
499                        .unwrap_or("/")
500                        .to_string();
501
502                    // Namespace under /fixtures/<id> unless this is a host-root path.
503                    let namespaced_path = if is_host_root_path(&original_path) {
504                        original_path.clone()
505                    } else if original_path == "/" {
506                        format!("/fixtures/{}", self.id)
507                    } else {
508                        format!("/fixtures/{}{}", self.id, original_path)
509                    };
510
511                    let status: u16 = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
512                    let headers: HashMap<String, String> = entry
513                        .get("headers")
514                        .and_then(|v| v.as_object())
515                        .map(|h| {
516                            h.iter()
517                                .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
518                                .collect()
519                        })
520                        .unwrap_or_default();
521
522                    // Load body bytes — use read() not read_to_string() to support binary files.
523                    let body_bytes: Vec<u8> = if let Some(inline) = entry.get("body_inline").and_then(|v| v.as_str()) {
524                        inline.as_bytes().to_vec()
525                    } else if let Some(file) = entry.get("body_file").and_then(|v| v.as_str()) {
526                        // body_file is resolved relative to `<fixtures>/responses/` first,
527                        // falling back to `<fixtures>/` for projects that store body assets at
528                        // the fixtures root rather than under a `responses/` subdir.
529                        let candidates = [fixtures_dir.join("responses").join(file), fixtures_dir.join(file)];
530                        let mut loaded = None;
531                        for abs in &candidates {
532                            if let Ok(bytes) = std::fs::read(abs) {
533                                loaded = Some(bytes);
534                                break;
535                            }
536                        }
537                        match loaded {
538                            Some(b) => b,
539                            None => {
540                                eprintln!(
541                                    "warning: cannot read body_file {} (tried {} and {})",
542                                    file,
543                                    candidates[0].display(),
544                                    candidates[1].display()
545                                );
546                                Vec::new()
547                            }
548                        }
549                    } else {
550                        Vec::new()
551                    };
552
553                    let delay_ms = entry.get("delay_ms").and_then(|v| v.as_u64());
554
555                    routes.push(ResolvedRoute {
556                        path: namespaced_path,
557                        original_path,
558                        response: MockResponse {
559                            status,
560                            body: None,
561                            stream_chunks: None,
562                            headers,
563                            delay_ms,
564                        },
565                        body_bytes,
566                    });
567                }
568            }
569        }
570
571        routes
572    }
573}
574
575// ---------------------------------------------------------------------------
576// Route table
577// ---------------------------------------------------------------------------
578
579#[derive(Clone, Debug)]
580struct MockRoute {
581    status: u16,
582    body: Vec<u8>,
583    stream_chunks: Vec<String>,
584    headers: Vec<(String, String)>,
585    /// Optional artificial delay applied before the handler returns. When set,
586    /// the handler `tokio::time::sleep`s for this many milliseconds before
587    /// constructing the response — used by timeout-error fixtures to force
588    /// client-side request timeouts.
589    delay_ms: Option<u64>,
590}
591
592type RouteTable = Arc<HashMap<String, MockRoute>>;
593
594// ---------------------------------------------------------------------------
595// Axum handler
596// ---------------------------------------------------------------------------
597
598async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
599    let path = req.uri().path().to_owned();
600
601    // Try exact match first
602    if let Some(route) = routes.get(&path) {
603        if let Some(delay_ms) = route.delay_ms {
604            tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
605        }
606        return serve_route(route);
607    }
608
609    // Try prefix match: find a route that is a prefix of the request path
610    // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
611    for (route_path, route) in routes.iter() {
612        if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
613            if let Some(delay_ms) = route.delay_ms {
614                tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
615            }
616            return serve_route(route);
617        }
618    }
619
620    Response::builder()
621        .status(StatusCode::NOT_FOUND)
622        .body(Body::from(format!("No mock route for {path}")))
623        .unwrap()
624        .into_response()
625}
626
627fn serve_route(route: &MockRoute) -> Response {
628    let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
629
630    if !route.stream_chunks.is_empty() {
631        let mut sse = String::new();
632        for chunk in &route.stream_chunks {
633            sse.push_str("data: ");
634            sse.push_str(chunk);
635            sse.push_str("\n\n");
636        }
637        sse.push_str("data: [DONE]\n\n");
638
639        let mut builder = Response::builder()
640            .status(status)
641            .header("content-type", "text/event-stream")
642            .header("cache-control", "no-cache");
643        for (name, value) in &route.headers {
644            builder = builder.header(name, value);
645        }
646        return builder.body(Body::from(sse)).unwrap().into_response();
647    }
648
649    // Only set the default content-type if the fixture does not override it.
650    // Inspect the first non-whitespace byte to detect JSON vs binary vs plain text.
651    let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
652    let mut builder = Response::builder().status(status);
653    if !has_content_type {
654        let first_nonws = route.body.iter().find(|&&b| b != b' ' && b != b'\t' && b != b'\n' && b != b'\r');
655        let default_ct = match first_nonws {
656            Some(&b'{') | Some(&b'[') => "application/json",
657            _ => "text/plain",
658        };
659        builder = builder.header("content-type", default_ct);
660    }
661    for (name, value) in &route.headers {
662        // Skip content-encoding headers — the mock server returns uncompressed bodies.
663        // Sending a content-encoding without actually encoding the body would cause
664        // clients to fail decompression.
665        if name.to_lowercase() == "content-encoding" {
666            continue;
667        }
668        // The <<absent>> sentinel means this header must NOT be present in the
669        // real server response — do not emit it from the mock server either.
670        if value == "<<absent>>" {
671            continue;
672        }
673        // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
674        // assert the header value matches the UUID pattern.
675        if value == "<<uuid>>" {
676            let uuid = format!(
677                "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
678                rand_u32(),
679                rand_u16(),
680                rand_u16() & 0x0fff,
681                (rand_u16() & 0x3fff) | 0x8000,
682                rand_u48(),
683            );
684            builder = builder.header(name, uuid);
685            continue;
686        }
687        builder = builder.header(name, value);
688    }
689    builder.body(Body::from(route.body.clone())).unwrap().into_response()
690}
691
692/// Generate a pseudo-random u32 using the current time nanoseconds.
693fn rand_u32() -> u32 {
694    use std::time::{SystemTime, UNIX_EPOCH};
695    let ns = SystemTime::now()
696        .duration_since(UNIX_EPOCH)
697        .map(|d| d.subsec_nanos())
698        .unwrap_or(0);
699    ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
700}
701
702fn rand_u16() -> u16 {
703    (rand_u32() & 0xffff) as u16
704}
705
706fn rand_u48() -> u64 {
707    ((rand_u32() as u64) << 16) | (rand_u16() as u64)
708}
709
710// ---------------------------------------------------------------------------
711// Fixture loading
712// ---------------------------------------------------------------------------
713
714/// Intermediate fixture-loading result: shared route table plus per-fixture host-root data.
715struct LoadedRoutes {
716    /// Routes namespaced under /fixtures/<id> for the shared listener.
717    shared: HashMap<String, MockRoute>,
718    /// For each fixture that has host-root routes: fixture_id → route table at host root.
719    per_fixture: HashMap<String, HashMap<String, MockRoute>>,
720}
721
722fn load_routes(fixtures_dir: &Path) -> LoadedRoutes {
723    let mut shared = HashMap::new();
724    let mut per_fixture: HashMap<String, HashMap<String, MockRoute>> = HashMap::new();
725    load_routes_recursive(fixtures_dir, fixtures_dir, &mut shared, &mut per_fixture);
726    LoadedRoutes { shared, per_fixture }
727}
728
729fn load_routes_recursive(
730    dir: &Path,
731    fixtures_root: &Path,
732    shared: &mut HashMap<String, MockRoute>,
733    per_fixture: &mut HashMap<String, HashMap<String, MockRoute>>,
734) {
735    let entries = match std::fs::read_dir(dir) {
736        Ok(e) => e,
737        Err(err) => {
738            eprintln!("warning: cannot read directory {}: {err}", dir.display());
739            return;
740        }
741    };
742
743    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
744    paths.sort();
745
746    for path in paths {
747        if path.is_dir() {
748            load_routes_recursive(&path, fixtures_root, shared, per_fixture);
749        } else if path.extension().is_some_and(|ext| ext == "json") {
750            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
751            if filename == "schema.json" || filename.starts_with('_') {
752                continue;
753            }
754            let content = match std::fs::read_to_string(&path) {
755                Ok(c) => c,
756                Err(err) => {
757                    eprintln!("warning: cannot read {}: {err}", path.display());
758                    continue;
759                }
760            };
761            let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
762                match serde_json::from_str(&content) {
763                    Ok(v) => v,
764                    Err(err) => {
765                        eprintln!("warning: cannot parse {}: {err}", path.display());
766                        continue;
767                    }
768                }
769            } else {
770                match serde_json::from_str::<Fixture>(&content) {
771                    Ok(f) => vec![f],
772                    Err(err) => {
773                        eprintln!("warning: cannot parse {}: {err}", path.display());
774                        continue;
775                    }
776                }
777            };
778
779            for fixture in fixtures {
780                let resolved_routes = fixture.as_routes(fixtures_root);
781                // A fixture needs host-root routing if either:
782                //  1. it serves a path the crawler fetches at host root (/robots*, /sitemap*), OR
783                //  2. it returns a 3xx Location header pointing to a host-root path inside the
784                //     same fixture (the engine resolves the Location against the host, not the
785                //     /fixtures/<id>/ namespace, so host-root serving is required for the
786                //     follow-up GET to hit the correct route).
787                let has_intra_fixture_redirect = resolved_routes.iter().any(|r| {
788                    // 3xx with relative Location header
789                    let location_redirect = (300..400).contains(&r.response.status)
790                        && r.response.headers.iter().any(|(name, value)| {
791                            name.eq_ignore_ascii_case("location") && value.starts_with('/')
792                        });
793                    // Refresh header with url=/...
794                    let refresh_redirect = r.response.headers.iter().any(|(name, value)| {
795                        if !name.eq_ignore_ascii_case("refresh") {
796                            return false;
797                        }
798                        let lower = value.to_ascii_lowercase();
799                        lower
800                            .find("url=")
801                            .map(|idx| value[idx + 4..].trim_start().starts_with('/'))
802                            .unwrap_or(false)
803                    });
804                    // HTML meta-refresh tag pointing to /...
805                    let body_lower_lossy = String::from_utf8_lossy(&r.body_bytes).to_ascii_lowercase();
806                    let meta_refresh = body_lower_lossy
807                        .split("http-equiv=\"refresh\"")
808                        .nth(1)
809                        .and_then(|s| s.split("content=").nth(1))
810                        .map(|s| {
811                            let trimmed = s.trim_start_matches(['"', '\'']);
812                            trimmed.contains("url=/")
813                        })
814                        .unwrap_or(false);
815                    location_redirect || refresh_redirect || meta_refresh
816                });
817                let has_host_root = has_intra_fixture_redirect
818                    || resolved_routes.iter().any(|r| is_host_root_path(&r.original_path));
819
820                for resolved in resolved_routes {
821                    let stream_chunks = resolved.response
822                        .stream_chunks
823                        .unwrap_or_default()
824                        .into_iter()
825                        .map(|c| match c {
826                            serde_json::Value::String(s) => s,
827                            other => serde_json::to_string(&other).unwrap_or_default(),
828                        })
829                        .collect();
830                    let mut headers: Vec<(String, String)> = resolved.response.headers.into_iter().collect();
831                    headers.sort_by(|a, b| a.0.cmp(&b.0));
832
833                    let mock_route = MockRoute {
834                        status: resolved.response.status,
835                        body: resolved.body_bytes,
836                        stream_chunks,
837                        headers,
838                        delay_ms: resolved.response.delay_ms,
839                    };
840
841                    // Always insert into the shared namespaced table.
842                    shared.insert(resolved.path.clone(), mock_route.clone());
843
844                    // For fixtures with host-root routes, also build a per-fixture table
845                    // where routes are mounted at their original (un-namespaced) paths.
846                    if has_host_root {
847                        per_fixture
848                            .entry(fixture.id.clone())
849                            .or_default()
850                            .insert(resolved.original_path.clone(), mock_route);
851                    }
852                }
853            }
854        }
855    }
856}
857
858// ---------------------------------------------------------------------------
859// Entry point
860// ---------------------------------------------------------------------------
861
862#[tokio::main]
863async fn main() {
864    let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
865    let fixtures_dir = Path::new(&fixtures_dir_arg);
866
867    let loaded = load_routes(fixtures_dir);
868    eprintln!("mock-server: loaded {} shared routes from {}", loaded.shared.len(), fixtures_dir.display());
869
870    // Shared namespaced server.
871    let shared_table: RouteTable = Arc::new(loaded.shared);
872    let shared_app = Router::new().fallback(handle_request).with_state(shared_table);
873
874    let shared_listener = TcpListener::bind("127.0.0.1:0")
875        .await
876        .expect("mock-server: failed to bind shared port");
877    let shared_addr: SocketAddr = shared_listener.local_addr().expect("mock-server: failed to get shared local addr");
878
879    // Per-fixture listeners for host-root routes (robots.txt, sitemap.xml, etc.).
880    // Sorted by fixture_id for deterministic output.
881    let mut fixture_ids: Vec<String> = loaded.per_fixture.keys().cloned().collect();
882    fixture_ids.sort();
883
884    let mut fixture_urls: HashMap<String, String> = HashMap::new();
885    for fixture_id in &fixture_ids {
886        let routes = loaded.per_fixture[fixture_id].clone();
887        eprintln!("mock-server: fixture {} has {} host-root routes", fixture_id, routes.len());
888        let table: RouteTable = Arc::new(routes);
889        let app = Router::new().fallback(handle_request).with_state(table);
890        let listener = TcpListener::bind("127.0.0.1:0")
891            .await
892            .expect("mock-server: failed to bind per-fixture port");
893        let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get per-fixture local addr");
894        fixture_urls.insert(fixture_id.clone(), format!("http://{addr}"));
895        tokio::spawn(async move {
896            axum::serve(listener, app).await.expect("mock-server: per-fixture server error");
897        });
898    }
899
900    // Print the shared URL so the parent process can read it.
901    println!("MOCK_SERVER_URL=http://{shared_addr}");
902
903    // Always print MOCK_SERVERS=... (empty `{}` when there are no host-root
904    // fixtures) so parent parsers — which read until they see this sentinel
905    // line — never block on a readline that never comes.
906    let mut sorted_pairs: Vec<(&String, &String)> = fixture_urls.iter().collect();
907    sorted_pairs.sort_by_key(|(k, _)| k.as_str());
908    let json_entries: Vec<String> = sorted_pairs
909        .iter()
910        .map(|(k, v)| format!("\"{}\":\"{}\"", k, v))
911        .collect();
912    println!("MOCK_SERVERS={{{}}}", json_entries.join(","));
913
914    // Flush stdout explicitly so the parent does not block waiting.
915    use std::io::Write;
916    std::io::stdout().flush().expect("mock-server: failed to flush stdout");
917
918    // Spawn the shared server in the background.
919    tokio::spawn(async move {
920        axum::serve(shared_listener, shared_app).await.expect("mock-server: shared server error");
921    });
922
923    // Block until stdin is closed — the parent process controls lifetime.
924    let stdin = io::stdin();
925    let mut lines = stdin.lock().lines();
926    while lines.next().is_some() {}
927}
928"#
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934
935    #[test]
936    fn render_mock_server_module_contains_struct_definition() {
937        let out = render_mock_server_module();
938        assert!(out.contains("pub struct MockRoute"));
939        assert!(out.contains("pub struct MockServer"));
940    }
941
942    #[test]
943    fn render_mock_server_binary_contains_main() {
944        let out = render_mock_server_binary();
945        assert!(out.contains("async fn main()"));
946        assert!(out.contains("MOCK_SERVER_URL=http://"));
947    }
948}