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