1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4use tokio::io::{AsyncReadExt, AsyncWriteExt};
5use tokio::net::TcpListener;
6
7const DEFAULT_PORT: u16 = 3333;
8const DEFAULT_HOST: &str = "127.0.0.1";
9const DASHBOARD_HTML: &str = include_str!("dashboard.html");
10
11pub async fn start(port: Option<u16>, host: Option<String>) {
12 let port = port.unwrap_or_else(|| {
13 std::env::var("LEAN_CTX_PORT")
14 .ok()
15 .and_then(|p| p.parse().ok())
16 .unwrap_or(DEFAULT_PORT)
17 });
18
19 let host = host.unwrap_or_else(|| {
20 std::env::var("LEAN_CTX_HOST")
21 .ok()
22 .unwrap_or_else(|| DEFAULT_HOST.to_string())
23 });
24
25 let addr = format!("{host}:{port}");
26 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
27
28 if is_local && dashboard_responding(&host, port) {
31 println!("\n lean-ctx dashboard already running → http://{host}:{port}");
32 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
33 open_browser(&format!("http://localhost:{port}"));
34 return;
35 }
36
37 let token = if !is_local {
38 let t = generate_token();
39 save_token(&t);
40 Some(Arc::new(t))
41 } else {
42 None
43 };
44
45 if !is_local {
46 let t = token.as_ref().unwrap();
47 eprintln!(
48 " \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
49 Bearer token: \x1b[1;32m{t}\x1b[0m\n \
50 Browser URL: http://<your-ip>:{port}/?token={t}"
51 );
52 }
53
54 let listener = match TcpListener::bind(&addr).await {
55 Ok(l) => l,
56 Err(e) => {
57 eprintln!("Failed to bind to {addr}: {e}");
58 std::process::exit(1);
59 }
60 };
61
62 let stats_path = crate::core::data_dir::lean_ctx_data_dir()
63 .map(|d| d.join("stats.json").display().to_string())
64 .unwrap_or_else(|_| "~/.lean-ctx/stats.json".to_string());
65
66 if host == "0.0.0.0" {
67 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
68 println!(" Local access: http://localhost:{port}");
69 } else {
70 println!("\n lean-ctx dashboard → http://{host}:{port}");
71 }
72 println!(" Stats file: {stats_path}");
73 println!(" Press Ctrl+C to stop\n");
74
75 if is_local {
76 open_browser(&format!("http://localhost:{port}"));
77 }
78 if crate::shell::is_container() && is_local {
79 println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
80 println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
81 println!(" docker run ... -p {port}:{port} ...");
82 println!();
83 }
84
85 loop {
86 if let Ok((stream, _)) = listener.accept().await {
87 let token_ref = token.clone();
88 tokio::spawn(handle_request(stream, token_ref));
89 }
90 }
91}
92
93fn generate_token() -> String {
94 use std::time::{SystemTime, UNIX_EPOCH};
95 let seed = SystemTime::now()
96 .duration_since(UNIX_EPOCH)
97 .unwrap_or_default()
98 .as_nanos();
99 format!("lctx_{:016x}", seed ^ 0xdeadbeef_cafebabe)
100}
101
102fn save_token(token: &str) {
103 if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
104 let _ = std::fs::create_dir_all(&dir);
105 let _ = std::fs::write(dir.join("dashboard.token"), token);
106 }
107}
108
109fn open_browser(url: &str) {
110 #[cfg(target_os = "macos")]
111 {
112 let _ = std::process::Command::new("open").arg(url).spawn();
113 }
114
115 #[cfg(target_os = "linux")]
116 {
117 let _ = std::process::Command::new("xdg-open")
118 .arg(url)
119 .stderr(std::process::Stdio::null())
120 .spawn();
121 }
122
123 #[cfg(target_os = "windows")]
124 {
125 let _ = std::process::Command::new("cmd")
126 .args(["/C", "start", url])
127 .spawn();
128 }
129}
130
131fn dashboard_responding(host: &str, port: u16) -> bool {
132 use std::io::{Read, Write};
133 use std::net::TcpStream;
134 use std::time::Duration;
135
136 let addr = format!("{host}:{port}");
137 let Ok(mut s) = TcpStream::connect_timeout(
138 &addr
139 .parse()
140 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
141 Duration::from_millis(150),
142 ) else {
143 return false;
144 };
145 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
146 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
147
148 let req = "GET /api/version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
149 if s.write_all(req.as_bytes()).is_err() {
150 return false;
151 }
152 let mut buf = [0u8; 256];
153 let Ok(n) = s.read(&mut buf) else {
154 return false;
155 };
156 let head = String::from_utf8_lossy(&buf[..n]);
157 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
158}
159
160async fn handle_request(mut stream: tokio::net::TcpStream, token: Option<Arc<String>>) {
161 let mut buf = vec![0u8; 4096];
162 let n = match stream.read(&mut buf).await {
163 Ok(n) if n > 0 => n,
164 _ => return,
165 };
166
167 let request = String::from_utf8_lossy(&buf[..n]);
168
169 let raw_path = request
170 .lines()
171 .next()
172 .and_then(|line| line.split_whitespace().nth(1))
173 .unwrap_or("/");
174
175 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
176 let p = &raw_path[..idx];
177 let qs = &raw_path[idx + 1..];
178 let tok = qs
179 .split('&')
180 .find_map(|pair| pair.strip_prefix("token="))
181 .map(|t| t.to_string());
182 (p.to_string(), tok)
183 } else {
184 (raw_path.to_string(), None)
185 };
186
187 let query_str = raw_path.find('?').map(|i| &raw_path[i + 1..]).unwrap_or("");
188
189 let is_api = path.starts_with("/api/");
190
191 if let Some(ref expected) = token {
192 let has_header_auth = check_auth(&request, expected);
193 let has_query_auth = query_token
194 .as_deref()
195 .map(|t| t == expected.as_str())
196 .unwrap_or(false);
197
198 if is_api && !has_header_auth && !has_query_auth {
199 let body = r#"{"error":"unauthorized"}"#;
200 let response = format!(
201 "HTTP/1.1 401 Unauthorized\r\n\
202 Content-Type: application/json\r\n\
203 Content-Length: {}\r\n\
204 WWW-Authenticate: Bearer\r\n\
205 Connection: close\r\n\
206 \r\n\
207 {body}",
208 body.len()
209 );
210 let _ = stream.write_all(response.as_bytes()).await;
211 return;
212 }
213 }
214
215 let path = path.as_str();
216
217 let compute =
218 std::panic::catch_unwind(|| route_response(path, query_str, &query_token, &token));
219 let (status, content_type, body) = match compute {
220 Ok(v) => v,
221 Err(_) => (
222 "500 Internal Server Error",
223 "application/json",
224 r#"{"error":"dashboard route panicked"}"#.to_string(),
225 ),
226 };
227
228 let cache_header = if content_type.starts_with("application/json") {
229 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
230 } else {
231 ""
232 };
233
234 let response = format!(
235 "HTTP/1.1 {status}\r\n\
236 Content-Type: {content_type}\r\n\
237 Content-Length: {}\r\n\
238 {cache_header}\
239 Access-Control-Allow-Origin: *\r\n\
240 Connection: close\r\n\
241 \r\n\
242 {body}",
243 body.len()
244 );
245
246 let _ = stream.write_all(response.as_bytes()).await;
247}
248
249fn route_response(
250 path: &str,
251 query_str: &str,
252 query_token: &Option<String>,
253 token: &Option<Arc<String>>,
254) -> (&'static str, &'static str, String) {
255 match path {
256 "/api/stats" => {
257 let store = crate::core::stats::load();
258 let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
259 ("200 OK", "application/json", json)
260 }
261 "/api/gain" => {
262 let env_model = std::env::var("LEAN_CTX_MODEL")
263 .or_else(|_| std::env::var("LCTX_MODEL"))
264 .ok();
265 let engine = crate::core::gain::GainEngine::load();
266 let payload = serde_json::json!({
267 "summary": engine.summary(env_model.as_deref()),
268 "tasks": engine.task_breakdown(),
269 "heatmap": engine.heatmap_gains(20),
270 });
271 let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
272 ("200 OK", "application/json", json)
273 }
274 "/api/mcp" => {
275 let mcp_path = crate::core::data_dir::lean_ctx_data_dir()
276 .map(|d| d.join("mcp-live.json"))
277 .unwrap_or_default();
278 let json = std::fs::read_to_string(&mcp_path).unwrap_or_else(|_| "{}".to_string());
279 ("200 OK", "application/json", json)
280 }
281 "/api/agents" => {
282 let json = build_agents_json();
283 ("200 OK", "application/json", json)
284 }
285 "/api/knowledge" => {
286 let project_root = detect_project_root_for_dashboard();
287 let _ =
288 crate::core::knowledge::ProjectKnowledge::migrate_legacy_empty_root(&project_root);
289 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
290 let json = serde_json::to_string(&knowledge).unwrap_or_else(|_| "{}".to_string());
291 ("200 OK", "application/json", json)
292 }
293 "/api/gotchas" => {
294 let project_root = detect_project_root_for_dashboard();
295 let store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
296 let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
297 ("200 OK", "application/json", json)
298 }
299 "/api/buddy" => {
300 let buddy = crate::core::buddy::BuddyState::compute();
301 let json = serde_json::to_string(&buddy).unwrap_or_else(|_| "{}".to_string());
302 ("200 OK", "application/json", json)
303 }
304 "/api/version" => {
305 let json = crate::core::version_check::version_info_json();
306 ("200 OK", "application/json", json)
307 }
308 "/api/pulse" => {
309 let stats_path = crate::core::data_dir::lean_ctx_data_dir()
310 .map(|d| d.join("stats.json"))
311 .unwrap_or_default();
312 let meta = std::fs::metadata(&stats_path).ok();
313 let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
314 let mtime = meta
315 .and_then(|m| m.modified().ok())
316 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
317 .map(|d| d.as_secs())
318 .unwrap_or(0);
319 use md5::Digest;
320 let hash = format!(
321 "{:x}",
322 md5::Md5::digest(format!("{size}-{mtime}").as_bytes())
323 );
324 let json = format!(r#"{{"hash":"{hash}","ts":{mtime}}}"#);
325 ("200 OK", "application/json", json)
326 }
327 "/api/heatmap" => {
328 let project_root = detect_project_root_for_dashboard();
329 let index = crate::core::graph_index::load_or_build(&project_root);
330 let entries = build_heatmap_json(&index);
331 ("200 OK", "application/json", entries)
332 }
333 "/api/events" => {
334 let evs = crate::core::events::load_events_from_file(200);
335 let json = serde_json::to_string(&evs).unwrap_or_else(|_| "[]".to_string());
336 ("200 OK", "application/json", json)
337 }
338 "/api/graph" => {
339 let root = detect_project_root_for_dashboard();
340 let index = crate::core::graph_index::load_or_build(&root);
341 let json = serde_json::to_string(&index).unwrap_or_else(|_| {
342 "{\"error\":\"failed to serialize project index\"}".to_string()
343 });
344 ("200 OK", "application/json", json)
345 }
346 "/api/call-graph" => {
347 let root = detect_project_root_for_dashboard();
348 let index = crate::core::graph_index::load_or_build(&root);
349 let call_graph = crate::core::call_graph::CallGraph::load_or_build(&root, &index);
350 let _ = call_graph.save();
351 let payload = serde_json::json!({
352 "project_root": call_graph.project_root,
353 "edges": call_graph.edges,
354 "file_hashes": call_graph.file_hashes,
355 "indexed_file_count": index.files.len(),
356 "indexed_symbol_count": index.symbols.len(),
357 "analyzed_file_count": call_graph.file_hashes.len(),
358 });
359 let json = serde_json::to_string(&payload)
360 .unwrap_or_else(|_| "{\"error\":\"failed to serialize call graph\"}".to_string());
361 ("200 OK", "application/json", json)
362 }
363 "/api/feedback" => {
364 let store = crate::core::feedback::FeedbackStore::load();
365 let json = serde_json::to_string(&store).unwrap_or_else(|_| {
366 "{\"error\":\"failed to serialize feedback store\"}".to_string()
367 });
368 ("200 OK", "application/json", json)
369 }
370 "/api/symbols" => {
371 let root = detect_project_root_for_dashboard();
372 let index = crate::core::graph_index::load_or_build(&root);
373 let q = extract_query_param(query_str, "q");
374 let kind = extract_query_param(query_str, "kind");
375 let json = build_symbols_json(&index, q.as_deref(), kind.as_deref());
376 ("200 OK", "application/json", json)
377 }
378 "/api/routes" => {
379 let root = detect_project_root_for_dashboard();
380 let index = crate::core::graph_index::load_or_build(&root);
381 let routes =
382 crate::core::route_extractor::extract_routes_from_project(&root, &index.files);
383 let route_candidate_count = index
384 .files
385 .keys()
386 .filter(|p| {
387 std::path::Path::new(p.as_str())
388 .extension()
389 .and_then(|e| e.to_str())
390 .map(|e| {
391 matches!(e, "js" | "ts" | "py" | "rs" | "java" | "rb" | "go" | "kt")
392 })
393 .unwrap_or(false)
394 })
395 .count();
396 let payload = serde_json::json!({
397 "routes": routes,
398 "indexed_file_count": index.files.len(),
399 "route_candidate_count": route_candidate_count,
400 });
401 let json =
402 serde_json::to_string(&payload).unwrap_or_else(|_| "{\"routes\":[]}".to_string());
403 ("200 OK", "application/json", json)
404 }
405 "/api/session" => {
406 let session = crate::core::session::SessionState::load_latest().unwrap_or_default();
407 let json = serde_json::to_string(&session)
408 .unwrap_or_else(|_| "{\"error\":\"failed to serialize session\"}".to_string());
409 ("200 OK", "application/json", json)
410 }
411 "/api/search-index" => {
412 let root_s = detect_project_root_for_dashboard();
413 let root = std::path::Path::new(&root_s);
414 let index = crate::core::vector_index::BM25Index::load_or_build(root);
415 let summary = bm25_index_summary_json(&index);
416 let json = serde_json::to_string(&summary).unwrap_or_else(|_| {
417 "{\"error\":\"failed to serialize search index summary\"}".to_string()
418 });
419 ("200 OK", "application/json", json)
420 }
421 "/api/search" => {
422 let q = extract_query_param(query_str, "q").unwrap_or_default();
423 let limit: usize = extract_query_param(query_str, "limit")
424 .and_then(|l| l.parse().ok())
425 .unwrap_or(20);
426 if q.trim().is_empty() {
427 (
428 "200 OK",
429 "application/json",
430 r#"{"results":[]}"#.to_string(),
431 )
432 } else {
433 let root_s = detect_project_root_for_dashboard();
434 let root = std::path::Path::new(&root_s);
435 let index = crate::core::vector_index::BM25Index::load_or_build(root);
436 let hits = index.search(&q, limit);
437 let results: Vec<serde_json::Value> = hits
438 .iter()
439 .map(|r| {
440 serde_json::json!({
441 "score": (r.score * 100.0).round() / 100.0,
442 "file_path": r.file_path,
443 "symbol_name": r.symbol_name,
444 "kind": r.kind,
445 "start_line": r.start_line,
446 "end_line": r.end_line,
447 "snippet": r.snippet,
448 })
449 })
450 .collect();
451 let json = serde_json::json!({ "results": results }).to_string();
452 ("200 OK", "application/json", json)
453 }
454 }
455 "/api/compression-demo" => {
456 let body = match extract_query_param(query_str, "path") {
457 None => r#"{"error":"missing path query parameter"}"#.to_string(),
458 Some(rel) => {
459 let task = extract_query_param(query_str, "task");
460 let root = detect_project_root_for_dashboard();
461 let root_pb = std::path::Path::new(&root);
462 let rel = normalize_dashboard_demo_path(&rel);
463 let candidate = std::path::Path::new(&rel);
464 let full = if candidate.is_absolute() {
465 candidate.to_path_buf()
466 } else {
467 let direct = root_pb.join(&rel);
468 if direct.exists() {
469 direct
470 } else {
471 let in_rust = root_pb.join("rust").join(&rel);
472 if in_rust.exists() {
473 in_rust
474 } else {
475 direct
476 }
477 }
478 };
479 match std::fs::read_to_string(&full) {
480 Ok(content) => {
481 let ext = full.extension().and_then(|e| e.to_str()).unwrap_or("rs");
482 let path_str = full.to_string_lossy().to_string();
483 let original_lines = content.lines().count();
484 let original_tokens = crate::core::tokens::count_tokens(&content);
485 let modes = compression_demo_modes_json(
486 &content,
487 &path_str,
488 ext,
489 original_tokens,
490 task.as_deref(),
491 );
492 let original_preview: String = content.chars().take(8000).collect();
493 serde_json::json!({
494 "path": path_str,
495 "task": task,
496 "original_lines": original_lines,
497 "original_tokens": original_tokens,
498 "original": original_preview,
499 "modes": modes,
500 })
501 .to_string()
502 }
503 Err(_) => r#"{"error":"failed to read file"}"#.to_string(),
504 }
505 }
506 };
507 ("200 OK", "application/json", body)
508 }
509 "/" | "/index.html" => {
510 let mut html = DASHBOARD_HTML.to_string();
511 if let Some(ref tok) = query_token {
512 let script = format!(
513 "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
514 tok.replace('"', "")
515 );
516 html = html.replacen("<head>", &format!("<head>{script}"), 1);
517 } else if let Some(ref t) = token {
518 let script = format!(
519 "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
520 t.as_str()
521 );
522 html = html.replacen("<head>", &format!("<head>{script}"), 1);
523 }
524 ("200 OK", "text/html; charset=utf-8", html)
525 }
526 "/favicon.ico" => ("204 No Content", "text/plain", String::new()),
527 _ => ("404 Not Found", "text/plain", "Not Found".to_string()),
528 }
529}
530
531fn check_auth(request: &str, expected_token: &str) -> bool {
532 for line in request.lines() {
533 let lower = line.to_lowercase();
534 if lower.starts_with("authorization:") {
535 let value = line["authorization:".len()..].trim();
536 if let Some(token) = value.strip_prefix("Bearer ") {
537 return token.trim() == expected_token;
538 }
539 if let Some(token) = value.strip_prefix("bearer ") {
540 return token.trim() == expected_token;
541 }
542 }
543 }
544 false
545}
546
547fn extract_query_param(qs: &str, key: &str) -> Option<String> {
548 for pair in qs.split('&') {
549 let (k, v) = match pair.split_once('=') {
550 Some(kv) => kv,
551 None => continue,
552 };
553 if k == key {
554 return Some(percent_decode_query_component(v));
555 }
556 }
557 None
558}
559
560fn percent_decode_query_component(s: &str) -> String {
561 let mut out: Vec<u8> = Vec::with_capacity(s.len());
562 let b = s.as_bytes();
563 let mut i = 0;
564 while i < b.len() {
565 match b[i] {
566 b'+' => {
567 out.push(b' ');
568 i += 1;
569 }
570 b'%' if i + 2 < b.len() => {
571 let h1 = (b[i + 1] as char).to_digit(16);
572 let h2 = (b[i + 2] as char).to_digit(16);
573 if let (Some(a), Some(d)) = (h1, h2) {
574 out.push(((a << 4) | d) as u8);
575 i += 3;
576 } else {
577 out.push(b'%');
578 i += 1;
579 }
580 }
581 _ => {
582 out.push(b[i]);
583 i += 1;
584 }
585 }
586 }
587 String::from_utf8_lossy(&out).into_owned()
588}
589
590fn normalize_dashboard_demo_path(path: &str) -> String {
591 let trimmed = path.trim();
592 if trimmed.is_empty() {
593 return String::new();
594 }
595
596 let candidate = Path::new(trimmed);
597 if candidate.is_absolute() || is_windows_absolute_path(trimmed) {
598 return trimmed.to_string();
599 }
600
601 trimmed
602 .trim_start_matches(['\\', '/'])
603 .replace('\\', std::path::MAIN_SEPARATOR_STR)
604}
605
606fn is_windows_absolute_path(path: &str) -> bool {
607 let bytes = path.as_bytes();
608 if bytes.len() >= 3
609 && bytes[0].is_ascii_alphabetic()
610 && bytes[1] == b':'
611 && matches!(bytes[2], b'\\' | b'/')
612 {
613 return true;
614 }
615
616 path.starts_with("\\\\") || path.starts_with("//")
617}
618
619fn compression_mode_json(output: &str, original_tokens: usize) -> serde_json::Value {
620 let tokens = crate::core::tokens::count_tokens(output);
621 let savings_pct = if original_tokens > 0 {
622 ((original_tokens.saturating_sub(tokens)) as f64 / original_tokens as f64 * 100.0).round()
623 as i64
624 } else {
625 0
626 };
627 serde_json::json!({
628 "output": output,
629 "tokens": tokens,
630 "savings_pct": savings_pct
631 })
632}
633
634fn compression_demo_modes_json(
635 content: &str,
636 path: &str,
637 ext: &str,
638 original_tokens: usize,
639 task: Option<&str>,
640) -> serde_json::Value {
641 let map_out = crate::core::signatures::extract_file_map(path, content);
642 let sig_out = crate::core::signatures::extract_signatures(content, ext)
643 .iter()
644 .map(|s| s.to_compact())
645 .collect::<Vec<_>>()
646 .join("\n");
647 let aggressive_out = crate::core::filters::aggressive_filter(content);
648 let entropy_out = crate::core::entropy::entropy_compress_adaptive(content, path).output;
649
650 let mut cache = crate::core::cache::SessionCache::new();
651 let reference_out =
652 crate::tools::ctx_read::handle(&mut cache, path, "reference", crate::tools::CrpMode::Off);
653 let task_out = task.filter(|t| !t.trim().is_empty()).map(|t| {
654 crate::tools::ctx_read::handle_with_task(
655 &mut cache,
656 path,
657 "task",
658 crate::tools::CrpMode::Off,
659 Some(t),
660 )
661 });
662
663 serde_json::json!({
664 "map": compression_mode_json(&map_out, original_tokens),
665 "signatures": compression_mode_json(&sig_out, original_tokens),
666 "reference": compression_mode_json(&reference_out, original_tokens),
667 "aggressive": compression_mode_json(&aggressive_out, original_tokens),
668 "entropy": compression_mode_json(&entropy_out, original_tokens),
669 "task": task_out.as_deref().map(|s| compression_mode_json(s, original_tokens)).unwrap_or(serde_json::Value::Null),
670 })
671}
672
673fn bm25_index_summary_json(index: &crate::core::vector_index::BM25Index) -> serde_json::Value {
674 let mut sorted: Vec<&crate::core::vector_index::CodeChunk> = index.chunks.iter().collect();
675 sorted.sort_by_key(|c| std::cmp::Reverse(c.token_count));
676 let top: Vec<serde_json::Value> = sorted
677 .into_iter()
678 .take(20)
679 .map(|c| {
680 serde_json::json!({
681 "file_path": c.file_path,
682 "symbol_name": c.symbol_name,
683 "token_count": c.token_count,
684 "kind": c.kind,
685 "start_line": c.start_line,
686 "end_line": c.end_line,
687 })
688 })
689 .collect();
690 let mut lang: HashMap<String, usize> = HashMap::new();
691 for c in &index.chunks {
692 let e = std::path::Path::new(&c.file_path)
693 .extension()
694 .and_then(|e| e.to_str())
695 .unwrap_or("")
696 .to_string();
697 *lang.entry(e).or_default() += 1;
698 }
699 serde_json::json!({
700 "doc_count": index.doc_count,
701 "chunk_count": index.chunks.len(),
702 "top_chunks_by_token_count": top,
703 "language_distribution": lang,
704 })
705}
706
707fn build_symbols_json(
708 index: &crate::core::graph_index::ProjectIndex,
709 query: Option<&str>,
710 kind: Option<&str>,
711) -> String {
712 let query = query
713 .map(|q| q.trim().to_lowercase())
714 .filter(|q| !q.is_empty());
715 let kind = kind
716 .map(|k| k.trim().to_lowercase())
717 .filter(|k| !k.is_empty());
718
719 let mut symbols: Vec<&crate::core::graph_index::SymbolEntry> = index
720 .symbols
721 .values()
722 .filter(|sym| {
723 let kind_match = match kind.as_ref() {
724 Some(k) => sym.kind.eq_ignore_ascii_case(k),
725 None => true,
726 };
727 let query_match = match query.as_ref() {
728 Some(q) => {
729 let name = sym.name.to_lowercase();
730 let file = sym.file.to_lowercase();
731 let symbol_kind = sym.kind.to_lowercase();
732 name.contains(q) || file.contains(q) || symbol_kind.contains(q)
733 }
734 None => true,
735 };
736 kind_match && query_match
737 })
738 .collect();
739
740 symbols.sort_by(|a, b| {
741 a.file
742 .cmp(&b.file)
743 .then_with(|| a.start_line.cmp(&b.start_line))
744 .then_with(|| a.name.cmp(&b.name))
745 });
746 symbols.truncate(500);
747
748 serde_json::to_string(
749 &symbols
750 .into_iter()
751 .map(|sym| {
752 serde_json::json!({
753 "name": sym.name,
754 "kind": sym.kind,
755 "file": sym.file,
756 "start_line": sym.start_line,
757 "end_line": sym.end_line,
758 "is_exported": sym.is_exported,
759 })
760 })
761 .collect::<Vec<_>>(),
762 )
763 .unwrap_or_else(|_| "[]".to_string())
764}
765
766fn build_heatmap_json(index: &crate::core::graph_index::ProjectIndex) -> String {
767 let mut connection_counts: std::collections::HashMap<String, usize> =
768 std::collections::HashMap::new();
769 for edge in &index.edges {
770 *connection_counts.entry(edge.from.clone()).or_default() += 1;
771 *connection_counts.entry(edge.to.clone()).or_default() += 1;
772 }
773
774 let max_tokens = index
775 .files
776 .values()
777 .map(|f| f.token_count)
778 .max()
779 .unwrap_or(1) as f64;
780 let max_connections = connection_counts.values().max().copied().unwrap_or(1) as f64;
781
782 let mut entries: Vec<serde_json::Value> = index
783 .files
784 .values()
785 .map(|f| {
786 let connections = connection_counts.get(&f.path).copied().unwrap_or(0);
787 let token_norm = f.token_count as f64 / max_tokens;
788 let conn_norm = connections as f64 / max_connections;
789 let heat = token_norm * 0.4 + conn_norm * 0.6;
790 serde_json::json!({
791 "path": f.path,
792 "tokens": f.token_count,
793 "connections": connections,
794 "language": f.language,
795 "heat": (heat * 100.0).round() / 100.0,
796 })
797 })
798 .collect();
799
800 entries.sort_by(|a, b| {
801 b["heat"]
802 .as_f64()
803 .unwrap_or(0.0)
804 .partial_cmp(&a["heat"].as_f64().unwrap_or(0.0))
805 .unwrap()
806 });
807
808 serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
809}
810
811fn build_agents_json() -> String {
812 let registry = crate::core::agents::AgentRegistry::load_or_create();
813 let agents: Vec<serde_json::Value> = registry
814 .agents
815 .iter()
816 .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
817 .map(|a| {
818 let age_min = (chrono::Utc::now() - a.last_active).num_minutes();
819 serde_json::json!({
820 "id": a.agent_id,
821 "type": a.agent_type,
822 "role": a.role,
823 "status": format!("{}", a.status),
824 "status_message": a.status_message,
825 "last_active_minutes_ago": age_min,
826 "pid": a.pid
827 })
828 })
829 .collect();
830
831 let pending_msgs = registry.scratchpad.len();
832
833 let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
834 .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".lean-ctx"))
835 .join("agents")
836 .join("shared");
837 let shared_count = if shared_dir.exists() {
838 std::fs::read_dir(&shared_dir)
839 .map(|rd| rd.count())
840 .unwrap_or(0)
841 } else {
842 0
843 };
844
845 serde_json::json!({
846 "agents": agents,
847 "total_active": agents.len(),
848 "pending_messages": pending_msgs,
849 "shared_contexts": shared_count
850 })
851 .to_string()
852}
853
854fn detect_project_root_for_dashboard() -> String {
855 if let Ok(explicit) = std::env::var("LEAN_CTX_DASHBOARD_PROJECT") {
856 if !explicit.trim().is_empty() {
857 return promote_to_git_root(&explicit);
858 }
859 }
860
861 if let Some(session) = crate::core::session::SessionState::load_latest() {
862 if let Some(root) = session.project_root.as_deref() {
865 if !root.trim().is_empty() {
866 if let Some(git_root) = git_root_for(root) {
867 return git_root;
868 }
869 if is_real_project(root) {
870 return root.to_string();
871 }
872 }
873 }
874 if let Some(cwd) = session.shell_cwd.as_deref() {
875 if !cwd.trim().is_empty() {
876 let r = crate::core::protocol::detect_project_root_or_cwd(cwd);
877 return promote_to_git_root(&r);
878 }
879 }
880 if let Some(last) = session.files_touched.last() {
881 if !last.path.trim().is_empty() {
882 if let Some(parent) = Path::new(&last.path).parent() {
883 let p = parent.to_string_lossy().to_string();
884 let r = crate::core::protocol::detect_project_root_or_cwd(&p);
885 return promote_to_git_root(&r);
886 }
887 }
888 }
889 }
890
891 let cwd = std::env::current_dir()
892 .map(|p| p.to_string_lossy().to_string())
893 .unwrap_or_else(|_| ".".to_string());
894 let r = crate::core::protocol::detect_project_root_or_cwd(&cwd);
895 promote_to_git_root(&r)
896}
897
898fn is_real_project(path: &str) -> bool {
899 let p = Path::new(path);
900 if !p.is_dir() {
901 return false;
902 }
903 const MARKERS: &[&str] = &[
904 ".git",
905 "Cargo.toml",
906 "package.json",
907 "go.mod",
908 "pyproject.toml",
909 "requirements.txt",
910 "pom.xml",
911 "build.gradle",
912 "CMakeLists.txt",
913 ".lean-ctx.toml",
914 ];
915 MARKERS.iter().any(|m| p.join(m).exists())
916}
917
918fn promote_to_git_root(path: &str) -> String {
919 git_root_for(path).unwrap_or_else(|| path.to_string())
920}
921
922fn git_root_for(path: &str) -> Option<String> {
923 let mut p = Path::new(path);
924 loop {
925 let git = p.join(".git");
926 if git.exists() {
927 return Some(p.to_string_lossy().to_string());
928 }
929 p = p.parent()?;
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 #[test]
938 fn check_auth_with_valid_bearer() {
939 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
940 assert!(check_auth(req, "lctx_abc123"));
941 }
942
943 #[test]
944 fn check_auth_with_invalid_bearer() {
945 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
946 assert!(!check_auth(req, "lctx_abc123"));
947 }
948
949 #[test]
950 fn check_auth_missing_header() {
951 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
952 assert!(!check_auth(req, "lctx_abc123"));
953 }
954
955 #[test]
956 fn check_auth_lowercase_bearer() {
957 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
958 assert!(check_auth(req, "lctx_abc123"));
959 }
960
961 #[test]
962 fn query_token_parsing() {
963 let raw_path = "/index.html?token=lctx_abc123&other=val";
964 let idx = raw_path.find('?').unwrap();
965 let qs = &raw_path[idx + 1..];
966 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
967 assert_eq!(tok, Some("lctx_abc123"));
968 }
969
970 #[test]
971 fn api_path_detection() {
972 assert!("/api/stats".starts_with("/api/"));
973 assert!("/api/version".starts_with("/api/"));
974 assert!(!"/".starts_with("/api/"));
975 assert!(!"/index.html".starts_with("/api/"));
976 assert!(!"/favicon.ico".starts_with("/api/"));
977 }
978
979 #[test]
980 fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
981 let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
982 assert_eq!(
983 normalized,
984 format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
985 );
986 }
987
988 #[test]
989 fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
990 let input = r"C:\repo\backend\list_tables.js";
991 assert_eq!(normalize_dashboard_demo_path(input), input);
992 }
993
994 #[test]
995 fn normalize_dashboard_demo_path_preserves_unc_path() {
996 let input = r"\\server\share\backend\list_tables.js";
997 assert_eq!(normalize_dashboard_demo_path(input), input);
998 }
999}