Skip to main content

lean_ctx/dashboard/
mod.rs

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