1use std::sync::mpsc;
2use std::thread::{self, JoinHandle};
3use std::time::Duration;
4
5use super::{InspectorRequest, InspectorResponse, RequestSender};
6
7pub 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 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 let (resp_tx, resp_rx) = mpsc::channel();
49
50 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 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 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 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 let (name, payload) = parse_action_body(body);
103 Some(InspectorRequest::ExecuteAction { name, payload })
104 }
105 ("POST", "/rewind") => {
106 let steps = parse_rewind_body(body);
108 Some(InspectorRequest::Rewind { steps })
109 }
110 ("POST", "/simulate") => {
111 Some(InspectorRequest::Simulate {
113 action: body.to_string(),
114 })
115 }
116 _ => None,
117 }
118}
119
120fn parse_action_body(body: &str) -> (String, String) {
121 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
133fn 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 let rest = rest.trim_start();
140 let rest = rest.strip_prefix(':')?;
141 let rest = rest.trim_start();
142
143 if rest.starts_with('"') {
144 let rest = &rest[1..];
146 let end = rest.find('"')?;
147 Some(rest[..end].to_string())
148 } else {
149 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
157fn 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 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); }
275}