1use std::sync::Arc;
2use subtle::ConstantTimeEq;
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4use tokio::net::TcpListener;
5
6const DEFAULT_PORT: u16 = 3333;
7const DEFAULT_HOST: &str = "127.0.0.1";
8const DASHBOARD_HTML: &str = include_str!("dashboard.html");
9
10const COCKPIT_INDEX_HTML: &str = include_str!("static/index.html");
11const COCKPIT_STYLE_CSS: &str = include_str!("static/style.css");
12const COCKPIT_LIB_API_JS: &str = include_str!("static/lib/api.js");
13const COCKPIT_LIB_FORMAT_JS: &str = include_str!("static/lib/format.js");
14const COCKPIT_LIB_ROUTER_JS: &str = include_str!("static/lib/router.js");
15const COCKPIT_LIB_CHARTS_JS: &str = include_str!("static/lib/charts.js");
16const COCKPIT_LIB_SHARED_JS: &str = include_str!("static/lib/shared.js");
17const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
18const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
19const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
20const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
21const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
22const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
23const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
24const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
25const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
26 include_str!("static/components/cockpit-compression.js");
27const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
28const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
29const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
30
31pub mod routes;
32
33pub async fn start(port: Option<u16>, host: Option<String>) {
34 let port = port.unwrap_or_else(|| {
35 std::env::var("LEAN_CTX_PORT")
36 .ok()
37 .and_then(|p| p.parse().ok())
38 .unwrap_or(DEFAULT_PORT)
39 });
40
41 let host = host.unwrap_or_else(|| {
42 std::env::var("LEAN_CTX_HOST")
43 .ok()
44 .unwrap_or_else(|| DEFAULT_HOST.to_string())
45 });
46
47 let addr = format!("{host}:{port}");
48 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
49
50 if is_local && dashboard_responding(&host, port) {
53 println!("\n lean-ctx dashboard already running → http://{host}:{port}");
54 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
55 if let Some(t) = load_saved_token() {
56 open_browser(&format!("http://localhost:{port}/?token={t}"));
57 } else {
58 open_browser(&format!("http://localhost:{port}"));
59 }
60 return;
61 }
62
63 let t = generate_token();
66 save_token(&t);
67 let token = Some(Arc::new(t));
68
69 if let Some(t) = token.as_ref() {
70 let masked = if t.len() > 12 {
71 format!("{}…{}", &t[..8], &t[t.len() - 4..])
72 } else {
73 t.to_string()
74 };
75 if is_local {
76 println!(" Auth: enabled (local)");
77 println!(" Browser URL: http://localhost:{port}/?token={t}");
78 } else {
79 eprintln!(
80 " \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
81 Bearer token: \x1b[1;32m{masked}\x1b[0m\n \
82 Browser URL: http://<your-ip>:{port}/?token={t}"
83 );
84 }
85 }
86
87 let listener = match TcpListener::bind(&addr).await {
88 Ok(l) => l,
89 Err(e) => {
90 eprintln!("Failed to bind to {addr}: {e}");
91 std::process::exit(1);
92 }
93 };
94
95 let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
96 |_| "~/.lean-ctx/stats.json".to_string(),
97 |d| d.join("stats.json").display().to_string(),
98 );
99
100 if host == "0.0.0.0" {
101 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
102 println!(" Local access: http://localhost:{port}");
103 } else {
104 println!("\n lean-ctx dashboard → http://{host}:{port}");
105 }
106 println!(" Stats file: {stats_path}");
107 println!(" Press Ctrl+C to stop\n");
108
109 if is_local {
110 if let Some(t) = token.as_ref() {
111 open_browser(&format!("http://localhost:{port}/?token={t}"));
112 } else {
113 open_browser(&format!("http://localhost:{port}"));
114 }
115 }
116 if crate::shell::is_container() && is_local {
117 println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
118 println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
119 println!(" docker run ... -p {port}:{port} ...");
120 println!();
121 }
122
123 loop {
124 if let Ok((stream, _)) = listener.accept().await {
125 let token_ref = token.clone();
126 tokio::spawn(handle_request(stream, token_ref));
127 }
128 }
129}
130
131fn generate_token() -> String {
132 let mut bytes = [0u8; 32];
133 getrandom::fill(&mut bytes).expect("CSPRNG unavailable — cannot generate secure token");
134 format!("lctx_{}", hex_lower(&bytes))
135}
136
137fn save_token(token: &str) {
138 if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
139 let _ = std::fs::create_dir_all(&dir);
140 let path = dir.join("dashboard.token");
141 #[cfg(unix)]
142 {
143 use std::io::Write;
144 use std::os::unix::fs::OpenOptionsExt;
145 let Ok(mut f) = std::fs::OpenOptions::new()
146 .write(true)
147 .create(true)
148 .truncate(true)
149 .mode(0o600)
150 .open(&path)
151 else {
152 return;
153 };
154 let _ = f.write_all(token.as_bytes());
155 }
156 #[cfg(not(unix))]
157 {
158 let _ = std::fs::write(&path, token);
159 }
160 }
161}
162
163fn load_saved_token() -> Option<String> {
164 let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
165 let path = dir.join("dashboard.token");
166 std::fs::read_to_string(path)
167 .ok()
168 .map(|s| s.trim().to_string())
169}
170
171pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
174 let mut result = String::with_capacity(html.len() + 128);
175 let mut remaining = html;
176 while let Some(pos) = remaining.find("<script") {
177 result.push_str(&remaining[..pos]);
178 let tag_start = &remaining[pos..];
179 let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
180 let tag = &tag_start[..=tag_end];
181 if tag.contains("src=") || tag.contains("nonce=") {
182 result.push_str(tag);
183 } else {
184 result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
185 }
186 remaining = &tag_start[tag_end + 1..];
187 }
188 result.push_str(remaining);
189 result
190}
191
192fn hex_lower(bytes: &[u8]) -> String {
193 const HEX: &[u8; 16] = b"0123456789abcdef";
194 let mut out = String::with_capacity(bytes.len() * 2);
195 for &b in bytes {
196 out.push(HEX[(b >> 4) as usize] as char);
197 out.push(HEX[(b & 0x0f) as usize] as char);
198 }
199 out
200}
201
202fn open_browser(url: &str) {
203 #[cfg(target_os = "macos")]
204 {
205 let _ = std::process::Command::new("open").arg(url).spawn();
206 }
207
208 #[cfg(target_os = "linux")]
209 {
210 let _ = std::process::Command::new("xdg-open")
211 .arg(url)
212 .stderr(std::process::Stdio::null())
213 .spawn();
214 }
215
216 #[cfg(target_os = "windows")]
217 {
218 let _ = std::process::Command::new("cmd")
219 .args(["/C", "start", url])
220 .spawn();
221 }
222}
223
224fn dashboard_responding(host: &str, port: u16) -> bool {
225 use std::io::{Read, Write};
226 use std::net::TcpStream;
227 use std::time::Duration;
228
229 let addr = format!("{host}:{port}");
230 let Ok(mut s) = TcpStream::connect_timeout(
231 &addr
232 .parse()
233 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
234 Duration::from_millis(150),
235 ) else {
236 return false;
237 };
238 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
239 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
240
241 let auth_header = load_saved_token()
242 .map(|t| format!("Authorization: Bearer {t}\r\n"))
243 .unwrap_or_default();
244
245 let req = format!(
246 "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
247 );
248 if s.write_all(req.as_bytes()).is_err() {
249 return false;
250 }
251 let mut buf = [0u8; 256];
252 let Ok(n) = s.read(&mut buf) else {
253 return false;
254 };
255 let head = String::from_utf8_lossy(&buf[..n]);
256 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
257}
258
259const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
260
261fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
262 for line in header_section.lines() {
263 let Some((k, v)) = line.split_once(':') else {
264 continue;
265 };
266 if k.trim().eq_ignore_ascii_case(name) {
267 return Some(v.trim());
268 }
269 }
270 None
271}
272
273fn host_loopback_aliases(host: &str) -> Vec<String> {
275 let mut v = vec![host.to_string()];
276 if let Some(port) = host.strip_prefix("127.0.0.1:") {
277 v.push(format!("localhost:{port}"));
278 }
279 if let Some(port) = host.strip_prefix("localhost:") {
280 v.push(format!("127.0.0.1:{port}"));
281 }
282 if let Some(port) = host.strip_prefix("[::1]:") {
283 v.push(format!("127.0.0.1:{port}"));
284 v.push(format!("localhost:{port}"));
285 }
286 v
287}
288
289fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
290 let origin = origin.trim_end_matches('/');
291 for h in host_loopback_aliases(host) {
292 if origin.eq_ignore_ascii_case(&format!("http://{h}"))
293 || origin.eq_ignore_ascii_case(&format!("https://{h}"))
294 {
295 return true;
296 }
297 }
298 false
299}
300
301fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
304 let uc = method.to_ascii_uppercase();
305 if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
306 return true;
307 }
308 if !path.starts_with("/api/") {
309 return true;
310 }
311 let Some(origin) = header_line_value(header_section, "Origin") else {
312 return true;
313 };
314 if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
315 return true;
316 }
317 let Some(host) = header_line_value(header_section, "Host") else {
318 return false;
319 };
320 origin_matches_dashboard_host(origin, host)
321}
322
323fn find_headers_end(buf: &[u8]) -> Option<usize> {
324 buf.windows(4).position(|w| w == b"\r\n\r\n")
325}
326
327fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
328 let text = String::from_utf8_lossy(header_section);
329 for line in text.lines() {
330 let Some((k, v)) = line.split_once(':') else {
331 continue;
332 };
333 if k.trim().eq_ignore_ascii_case("content-length") {
334 return v.trim().parse::<usize>().ok();
335 }
336 }
337 Some(0)
338}
339
340async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
341 let mut buf = Vec::new();
342 let mut tmp = [0u8; 8192];
343 loop {
344 if let Some(end) = find_headers_end(&buf) {
345 let cl = parse_content_length_header(&buf[..end])?;
346 let total = end + 4 + cl;
347 if total > MAX_HTTP_MESSAGE {
348 return None;
349 }
350 if buf.len() >= total {
351 buf.truncate(total);
352 return Some(buf);
353 }
354 } else if buf.len() > 65_536 {
355 return None;
356 }
357
358 let n = stream.read(&mut tmp).await.ok()?;
359 if n == 0 {
360 return None;
361 }
362 buf.extend_from_slice(&tmp[..n]);
363 if buf.len() > MAX_HTTP_MESSAGE {
364 return None;
365 }
366 }
367}
368
369async fn handle_request(mut stream: tokio::net::TcpStream, token: Option<Arc<String>>) {
370 let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
371
372 let Some(buf) = read_http_message(&mut stream).await else {
373 return;
374 };
375 let Some(header_end) = find_headers_end(&buf) else {
376 return;
377 };
378 let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
379 let body_start = header_end + 4;
380 let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
381 return;
382 };
383 if buf.len() < body_start + content_len {
384 return;
385 }
386 let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
387 .unwrap_or("")
388 .to_string();
389
390 let first = header_text.lines().next().unwrap_or("");
391 let mut parts = first.split_whitespace();
392 let method = parts.next().unwrap_or("GET").to_string();
393 let raw_path = parts.next().unwrap_or("/").to_string();
394
395 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
396 let p = &raw_path[..idx];
397 let qs = &raw_path[idx + 1..];
398 let tok = qs
399 .split('&')
400 .find_map(|pair| pair.strip_prefix("token="))
401 .map(std::string::ToString::to_string);
402 (p.to_string(), tok)
403 } else {
404 (raw_path.clone(), None)
405 };
406
407 let query_str = raw_path
408 .find('?')
409 .map_or(String::new(), |i| raw_path[i + 1..].to_string());
410
411 let is_api = path.starts_with("/api/");
412 let requires_auth = is_api || path == "/metrics";
413
414 if let Some(ref expected) = token {
415 let has_header_auth = check_auth(&header_text, expected);
416
417 if requires_auth && !has_header_auth {
418 let body = r#"{"error":"unauthorized"}"#;
419 let response = format!(
420 "HTTP/1.1 401 Unauthorized\r\n\
421 Content-Type: application/json\r\n\
422 Content-Length: {}\r\n\
423 WWW-Authenticate: Bearer\r\n\
424 Connection: close\r\n\
425 \r\n\
426 {body}",
427 body.len()
428 );
429 let _ = stream.write_all(response.as_bytes()).await;
430 return;
431 }
432
433 if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
434 let body = r#"{"error":"forbidden"}"#;
435 let response = format!(
436 "HTTP/1.1 403 Forbidden\r\n\
437 Content-Type: application/json\r\n\
438 Content-Length: {}\r\n\
439 Connection: close\r\n\
440 \r\n\
441 {body}",
442 body.len()
443 );
444 let _ = stream.write_all(response.as_bytes()).await;
445 return;
446 }
447 }
448
449 let path = path.as_str();
450 let query_str = query_str.as_str();
451 let method = method.as_str();
452
453 let compute = std::panic::catch_unwind(|| {
454 routes::route_response(
455 path,
456 query_str,
457 query_token.as_ref(),
458 token.as_ref(),
459 is_loopback,
460 method,
461 &body_str,
462 )
463 });
464 let (status, content_type, mut body) = match compute {
465 Ok(v) => v,
466 Err(_) => (
467 "500 Internal Server Error",
468 "application/json",
469 r#"{"error":"dashboard route panicked"}"#.to_string(),
470 ),
471 };
472
473 let cache_header = if content_type.starts_with("application/json") {
474 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
475 } else if content_type.starts_with("application/javascript")
476 || content_type.starts_with("text/css")
477 {
478 "Cache-Control: no-cache, must-revalidate\r\n"
479 } else {
480 ""
481 };
482
483 let nonce = {
484 let mut nb = [0u8; 16];
485 getrandom::fill(&mut nb).expect("CSPRNG unavailable — cannot generate CSP nonce");
486 hex_lower(&nb)
487 };
488 if content_type.contains("text/html") {
489 body = add_nonce_to_inline_scripts(&body, &nonce);
490 }
491 let security_headers = format!(
492 "X-Content-Type-Options: nosniff\r\n\
493 X-Frame-Options: DENY\r\n\
494 Referrer-Policy: no-referrer\r\n\
495 Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'\r\n"
496 );
497
498 let response = format!(
499 "HTTP/1.1 {status}\r\n\
500 Content-Type: {content_type}\r\n\
501 Content-Length: {}\r\n\
502 {cache_header}\
503 {security_headers}\
504 Connection: close\r\n\
505 \r\n\
506 {body}",
507 body.len()
508 );
509
510 let _ = stream.write_all(response.as_bytes()).await;
511}
512
513fn check_auth(request: &str, expected_token: &str) -> bool {
514 for line in request.lines() {
515 let lower = line.to_lowercase();
516 if lower.starts_with("authorization:") {
517 let value = line["authorization:".len()..].trim();
518 if let Some(token) = value
519 .strip_prefix("Bearer ")
520 .or_else(|| value.strip_prefix("bearer "))
521 {
522 return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
523 }
524 }
525 }
526 false
527}
528
529fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
530 if a.len() != b.len() {
531 return false;
532 }
533 bool::from(a.ct_eq(b))
534}
535
536#[cfg(test)]
537mod tests {
538 use super::routes::helpers::normalize_dashboard_demo_path;
539 use super::*;
540 use tempfile::tempdir;
541
542 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
543
544 #[test]
545 fn check_auth_with_valid_bearer() {
546 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
547 assert!(check_auth(req, "lctx_abc123"));
548 }
549
550 #[test]
551 fn check_auth_with_invalid_bearer() {
552 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
553 assert!(!check_auth(req, "lctx_abc123"));
554 }
555
556 #[test]
557 fn check_auth_missing_header() {
558 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
559 assert!(!check_auth(req, "lctx_abc123"));
560 }
561
562 #[test]
563 fn check_auth_lowercase_bearer() {
564 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
565 assert!(check_auth(req, "lctx_abc123"));
566 }
567
568 #[test]
569 fn query_token_parsing() {
570 let raw_path = "/index.html?token=lctx_abc123&other=val";
571 let idx = raw_path.find('?').unwrap();
572 let qs = &raw_path[idx + 1..];
573 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
574 assert_eq!(tok, Some("lctx_abc123"));
575 }
576
577 #[test]
578 fn api_path_detection() {
579 assert!("/api/stats".starts_with("/api/"));
580 assert!("/api/version".starts_with("/api/"));
581 assert!(!"/".starts_with("/api/"));
582 assert!(!"/index.html".starts_with("/api/"));
583 assert!(!"/favicon.ico".starts_with("/api/"));
584 }
585
586 #[test]
587 fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
588 let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
589 assert_eq!(
590 normalized,
591 format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
592 );
593 }
594
595 #[test]
596 fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
597 let input = r"C:\repo\backend\list_tables.js";
598 assert_eq!(normalize_dashboard_demo_path(input), input);
599 }
600
601 #[test]
602 fn normalize_dashboard_demo_path_preserves_unc_path() {
603 let input = r"\\server\share\backend\list_tables.js";
604 assert_eq!(normalize_dashboard_demo_path(input), input);
605 }
606
607 #[test]
608 fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
609 assert_eq!(
610 normalize_dashboard_demo_path("./src/main.rs"),
611 "src/main.rs"
612 );
613 assert_eq!(
614 normalize_dashboard_demo_path(r".\src\main.rs"),
615 format!("src{}main.rs", std::path::MAIN_SEPARATOR)
616 );
617 }
618
619 #[test]
620 fn api_profile_returns_json() {
621 let (_status, _ct, body) =
622 routes::route_response("/api/profile", "", None, None, false, "GET", "");
623 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
624 assert!(v.get("active_name").is_some(), "missing active_name");
625 assert!(
626 v.pointer("/profile/profile/name")
627 .and_then(|n| n.as_str())
628 .is_some(),
629 "missing profile.profile.name"
630 );
631 assert!(v.get("available").and_then(|a| a.as_array()).is_some());
632 }
633
634 #[test]
635 fn api_episodes_returns_json() {
636 let (_status, _ct, body) =
637 routes::route_response("/api/episodes", "", None, None, false, "GET", "");
638 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
639 assert!(v.get("project_hash").is_some());
640 assert!(v.get("stats").is_some());
641 assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
642 }
643
644 #[test]
645 fn api_procedures_returns_json() {
646 let (_status, _ct, body) =
647 routes::route_response("/api/procedures", "", None, None, false, "GET", "");
648 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
649 assert!(v.get("project_hash").is_some());
650 assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
651 assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
652 }
653
654 #[test]
655 fn api_compression_demo_heals_moved_file_paths() {
656 let _g = ENV_LOCK.lock().expect("env lock");
657 let td = tempdir().expect("tempdir");
658 let root = td.path();
659 std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
660 std::fs::write(
661 root.join("src").join("moved").join("foo.rs"),
662 "pub fn foo() { println!(\"hi\"); }\n",
663 )
664 .expect("write foo.rs");
665
666 let root_s = root.to_string_lossy().to_string();
667 std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
668
669 let (_status, _ct, body) = routes::route_response(
670 "/api/compression-demo",
671 "path=src/foo.rs",
672 None,
673 None,
674 false,
675 "GET",
676 "",
677 );
678 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
679 assert!(v.get("error").is_none(), "unexpected error: {body}");
680 assert_eq!(
681 v.get("resolved_from").and_then(|x| x.as_str()),
682 Some("src/moved/foo.rs")
683 );
684
685 std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
686 if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
687 let _ = std::fs::remove_dir_all(dir);
688 }
689 }
690}