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