Skip to main content

arcane_engine/agent/
inspector.rs

1use std::sync::mpsc;
2use std::thread::{self, JoinHandle};
3use std::time::Duration;
4
5use super::{InspectorRequest, InspectorResponse, RequestSender};
6
7/// Start the HTTP inspector server on a background thread.
8/// Returns a join handle for the server thread.
9pub fn start_inspector(port: u16, request_tx: RequestSender) -> JoinHandle<()> {
10    thread::spawn(move || {
11        let addr = format!("0.0.0.0:{port}");
12        let server = match tiny_http::Server::http(&addr) {
13            Ok(s) => s,
14            Err(e) => {
15                eprintln!("[inspector] Failed to start on {addr}: {e}");
16                return;
17            }
18        };
19
20        eprintln!("[inspector] Listening on http://localhost:{port}");
21
22        for mut request in server.incoming_requests() {
23            let url = request.url().to_string();
24            let method = request.method().as_str().to_uppercase();
25
26            // Read body for POST requests
27            let body = if method == "POST" {
28                let mut buf = String::new();
29                let _ = request.as_reader().read_to_string(&mut buf);
30                buf
31            } else {
32                String::new()
33            };
34
35            let inspector_req = match parse_route(&method, &url, &body) {
36                Some(req) => req,
37                None => {
38                    let resp = build_http_response(InspectorResponse::error(
39                        404,
40                        format!("Unknown route: {method} {url}"),
41                    ));
42                    let _ = request.respond(resp);
43                    continue;
44                }
45            };
46
47            // Create a one-shot response channel
48            let (resp_tx, resp_rx) = mpsc::channel();
49
50            // Send request to game loop
51            if request_tx.send((inspector_req, resp_tx)).is_err() {
52                let resp = build_http_response(InspectorResponse::error(
53                    503,
54                    "Game loop disconnected".into(),
55                ));
56                let _ = request.respond(resp);
57                continue;
58            }
59
60            // Wait for response with timeout
61            let inspector_resp = match resp_rx.recv_timeout(Duration::from_secs(5)) {
62                Ok(resp) => resp,
63                Err(_) => InspectorResponse::error(504, "Game loop timeout".into()),
64            };
65
66            let resp = build_http_response(inspector_resp);
67            let _ = request.respond(resp);
68        }
69    })
70}
71
72fn parse_route(method: &str, url: &str, body: &str) -> Option<InspectorRequest> {
73    // Strip query string for matching
74    let path = url.split('?').next().unwrap_or(url);
75
76    match (method, path) {
77        ("GET", "/health") => Some(InspectorRequest::Health),
78        ("GET", "/state") => Some(InspectorRequest::GetState { path: None }),
79        ("GET", p) if p.starts_with("/state/") => {
80            let state_path = p.strip_prefix("/state/").unwrap_or("");
81            Some(InspectorRequest::GetState {
82                path: Some(state_path.to_string()),
83            })
84        }
85        ("GET", "/describe") => {
86            // Parse verbosity from query string
87            let verbosity = url
88                .split('?')
89                .nth(1)
90                .and_then(|qs| {
91                    qs.split('&')
92                        .find(|p| p.starts_with("verbosity="))
93                        .map(|p| p.strip_prefix("verbosity=").unwrap_or("").to_string())
94                });
95            Some(InspectorRequest::Describe { verbosity })
96        }
97        ("GET", "/actions") => Some(InspectorRequest::ListActions),
98        ("GET", "/history") => Some(InspectorRequest::GetHistory),
99        ("POST", "/action") => {
100            // Parse action name and payload from JSON body
101            // Simple JSON parsing: {"name": "...", "payload": ...}
102            let (name, payload) = parse_action_body(body);
103            Some(InspectorRequest::ExecuteAction { name, payload })
104        }
105        ("POST", "/rewind") => {
106            // Parse steps from JSON body: {"steps": N}
107            let steps = parse_rewind_body(body);
108            Some(InspectorRequest::Rewind { steps })
109        }
110        ("POST", "/simulate") => {
111            // Body is the action string/JSON
112            Some(InspectorRequest::Simulate {
113                action: body.to_string(),
114            })
115        }
116        _ => None,
117    }
118}
119
120fn parse_action_body(body: &str) -> (String, String) {
121    // Simple extraction — find "name" and "payload" fields
122    let name = extract_json_string(body, "name").unwrap_or_default();
123    let payload = extract_json_value(body, "payload").unwrap_or_else(|| "{}".to_string());
124    (name, payload)
125}
126
127fn parse_rewind_body(body: &str) -> u32 {
128    extract_json_string(body, "steps")
129        .and_then(|s| s.parse().ok())
130        .unwrap_or(1)
131}
132
133/// Extract a string value for a given key from simple JSON.
134fn extract_json_string(json: &str, key: &str) -> Option<String> {
135    let pattern = format!("\"{}\"", key);
136    let start = json.find(&pattern)?;
137    let rest = &json[start + pattern.len()..];
138    // Skip whitespace and colon
139    let rest = rest.trim_start();
140    let rest = rest.strip_prefix(':')?;
141    let rest = rest.trim_start();
142
143    if rest.starts_with('"') {
144        // String value
145        let rest = &rest[1..];
146        let end = rest.find('"')?;
147        Some(rest[..end].to_string())
148    } else {
149        // Number or other — read until comma, brace, or whitespace
150        let end = rest
151            .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
152            .unwrap_or(rest.len());
153        Some(rest[..end].to_string())
154    }
155}
156
157/// Extract a raw JSON value for a given key.
158fn extract_json_value(json: &str, key: &str) -> Option<String> {
159    let pattern = format!("\"{}\"", key);
160    let start = json.find(&pattern)?;
161    let rest = &json[start + pattern.len()..];
162    let rest = rest.trim_start();
163    let rest = rest.strip_prefix(':')?;
164    let rest = rest.trim_start();
165
166    if rest.starts_with('{') {
167        // Find matching brace
168        let mut depth = 0;
169        for (i, c) in rest.char_indices() {
170            match c {
171                '{' => depth += 1,
172                '}' => {
173                    depth -= 1;
174                    if depth == 0 {
175                        return Some(rest[..=i].to_string());
176                    }
177                }
178                _ => {}
179            }
180        }
181        None
182    } else if rest.starts_with('"') {
183        let inner = &rest[1..];
184        let end = inner.find('"')?;
185        Some(format!("\"{}\"", &inner[..end]))
186    } else {
187        let end = rest
188            .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
189            .unwrap_or(rest.len());
190        Some(rest[..end].to_string())
191    }
192}
193
194fn build_http_response(resp: InspectorResponse) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
195    let data = resp.body.into_bytes();
196    let data_len = data.len();
197
198    let status = tiny_http::StatusCode(resp.status);
199    let content_type =
200        tiny_http::Header::from_bytes(&b"Content-Type"[..], resp.content_type.as_bytes()).unwrap();
201    let cors =
202        tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap();
203    let cors_headers = tiny_http::Header::from_bytes(
204        &b"Access-Control-Allow-Headers"[..],
205        &b"Content-Type"[..],
206    )
207    .unwrap();
208    let cors_methods = tiny_http::Header::from_bytes(
209        &b"Access-Control-Allow-Methods"[..],
210        &b"GET, POST, OPTIONS"[..],
211    )
212    .unwrap();
213
214    tiny_http::Response::new(
215        status,
216        vec![content_type, cors, cors_headers, cors_methods],
217        std::io::Cursor::new(data),
218        Some(data_len),
219        None,
220    )
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn parse_route_health() {
229        let req = parse_route("GET", "/health", "").unwrap();
230        assert!(matches!(req, InspectorRequest::Health));
231    }
232
233    #[test]
234    fn parse_route_state_with_path() {
235        let req = parse_route("GET", "/state/player.hp", "").unwrap();
236        match req {
237            InspectorRequest::GetState { path } => {
238                assert_eq!(path, Some("player.hp".to_string()));
239            }
240            _ => panic!("Expected GetState"),
241        }
242    }
243
244    #[test]
245    fn parse_route_describe_with_verbosity() {
246        let req = parse_route("GET", "/describe?verbosity=full", "").unwrap();
247        match req {
248            InspectorRequest::Describe { verbosity } => {
249                assert_eq!(verbosity, Some("full".to_string()));
250            }
251            _ => panic!("Expected Describe"),
252        }
253    }
254
255    #[test]
256    fn parse_route_unknown_returns_none() {
257        assert!(parse_route("GET", "/unknown", "").is_none());
258        assert!(parse_route("DELETE", "/health", "").is_none());
259    }
260
261    #[test]
262    fn parse_action_body_extracts_name_and_payload() {
263        let body = r#"{"name": "move", "payload": {"dx": 1, "dy": 0}}"#;
264        let (name, payload) = parse_action_body(body);
265        assert_eq!(name, "move");
266        assert_eq!(payload, r#"{"dx": 1, "dy": 0}"#);
267    }
268
269    #[test]
270    fn parse_rewind_body_extracts_steps() {
271        assert_eq!(parse_rewind_body(r#"{"steps": 5}"#), 5);
272        assert_eq!(parse_rewind_body(r#"{"steps": "3"}"#), 3);
273        assert_eq!(parse_rewind_body("{}"), 1); // default
274    }
275}