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