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