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 = dirs::home_dir()
63 .map(|h| h.join(".lean-ctx/stats.json"))
64 .map(|p| p.display().to_string())
65 .unwrap_or_else(|| "~/.lean-ctx/stats.json".to_string());
66
67 if host == "0.0.0.0" {
68 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
69 println!(" Local access: http://localhost:{port}");
70 } else {
71 println!("\n lean-ctx dashboard → http://{host}:{port}");
72 }
73 println!(" Stats file: {stats_path}");
74 println!(" Press Ctrl+C to stop\n");
75
76 if is_local {
77 open_browser(&format!("http://localhost:{port}"));
78 }
79
80 loop {
81 if let Ok((stream, _)) = listener.accept().await {
82 let token_ref = token.clone();
83 tokio::spawn(handle_request(stream, token_ref));
84 }
85 }
86}
87
88fn generate_token() -> String {
89 use std::time::{SystemTime, UNIX_EPOCH};
90 let seed = SystemTime::now()
91 .duration_since(UNIX_EPOCH)
92 .unwrap_or_default()
93 .as_nanos();
94 format!("lctx_{:016x}", seed ^ 0xdeadbeef_cafebabe)
95}
96
97fn save_token(token: &str) {
98 if let Some(dir) = dirs::home_dir().map(|h| h.join(".lean-ctx")) {
99 let _ = std::fs::create_dir_all(&dir);
100 let _ = std::fs::write(dir.join("dashboard.token"), token);
101 }
102}
103
104fn open_browser(url: &str) {
105 #[cfg(target_os = "macos")]
106 {
107 let _ = std::process::Command::new("open").arg(url).spawn();
108 }
109
110 #[cfg(target_os = "linux")]
111 {
112 let _ = std::process::Command::new("xdg-open")
113 .arg(url)
114 .stderr(std::process::Stdio::null())
115 .spawn();
116 }
117
118 #[cfg(target_os = "windows")]
119 {
120 let _ = std::process::Command::new("cmd")
121 .args(["/C", "start", url])
122 .spawn();
123 }
124}
125
126fn dashboard_responding(host: &str, port: u16) -> bool {
127 use std::io::{Read, Write};
128 use std::net::TcpStream;
129 use std::time::Duration;
130
131 let addr = format!("{host}:{port}");
132 let Ok(mut s) = TcpStream::connect_timeout(
133 &addr
134 .parse()
135 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
136 Duration::from_millis(150),
137 ) else {
138 return false;
139 };
140 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
141 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
142
143 let req = "GET /api/version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
144 if s.write_all(req.as_bytes()).is_err() {
145 return false;
146 }
147 let mut buf = [0u8; 256];
148 let Ok(n) = s.read(&mut buf) else {
149 return false;
150 };
151 let head = String::from_utf8_lossy(&buf[..n]);
152 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
153}
154
155async fn handle_request(mut stream: tokio::net::TcpStream, token: Option<Arc<String>>) {
156 let mut buf = vec![0u8; 4096];
157 let n = match stream.read(&mut buf).await {
158 Ok(n) if n > 0 => n,
159 _ => return,
160 };
161
162 let request = String::from_utf8_lossy(&buf[..n]);
163
164 let raw_path = request
165 .lines()
166 .next()
167 .and_then(|line| line.split_whitespace().nth(1))
168 .unwrap_or("/");
169
170 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
171 let p = &raw_path[..idx];
172 let qs = &raw_path[idx + 1..];
173 let tok = qs
174 .split('&')
175 .find_map(|pair| pair.strip_prefix("token="))
176 .map(|t| t.to_string());
177 (p.to_string(), tok)
178 } else {
179 (raw_path.to_string(), None)
180 };
181
182 let query_str = raw_path.find('?').map(|i| &raw_path[i + 1..]).unwrap_or("");
183
184 let is_api = path.starts_with("/api/");
185
186 if let Some(ref expected) = token {
187 let has_header_auth = check_auth(&request, expected);
188 let has_query_auth = query_token
189 .as_deref()
190 .map(|t| t == expected.as_str())
191 .unwrap_or(false);
192
193 if is_api && !has_header_auth && !has_query_auth {
194 let body = r#"{"error":"unauthorized"}"#;
195 let response = format!(
196 "HTTP/1.1 401 Unauthorized\r\n\
197 Content-Type: application/json\r\n\
198 Content-Length: {}\r\n\
199 WWW-Authenticate: Bearer\r\n\
200 Connection: close\r\n\
201 \r\n\
202 {body}",
203 body.len()
204 );
205 let _ = stream.write_all(response.as_bytes()).await;
206 return;
207 }
208 }
209
210 let path = path.as_str();
211
212 let (status, content_type, body) = match path {
213 "/api/stats" => {
214 let store = crate::core::stats::load();
215 let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
216 ("200 OK", "application/json", json)
217 }
218 "/api/mcp" => {
219 let mcp_path = dirs::home_dir()
220 .map(|h| h.join(".lean-ctx").join("mcp-live.json"))
221 .unwrap_or_default();
222 let json = std::fs::read_to_string(&mcp_path).unwrap_or_else(|_| "{}".to_string());
223 ("200 OK", "application/json", json)
224 }
225 "/api/agents" => {
226 let json = build_agents_json();
227 ("200 OK", "application/json", json)
228 }
229 "/api/knowledge" => {
230 let project_root = detect_project_root_for_dashboard();
231 let _ =
232 crate::core::knowledge::ProjectKnowledge::migrate_legacy_empty_root(&project_root);
233 let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
234 let json = serde_json::to_string(&knowledge).unwrap_or_else(|_| "{}".to_string());
235 ("200 OK", "application/json", json)
236 }
237 "/api/gotchas" => {
238 let project_root = detect_project_root_for_dashboard();
239 let store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
240 let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
241 ("200 OK", "application/json", json)
242 }
243 "/api/buddy" => {
244 let buddy = crate::core::buddy::BuddyState::compute();
245 let json = serde_json::to_string(&buddy).unwrap_or_else(|_| "{}".to_string());
246 ("200 OK", "application/json", json)
247 }
248 "/api/version" => {
249 let json = crate::core::version_check::version_info_json();
250 ("200 OK", "application/json", json)
251 }
252 "/api/heatmap" => {
253 let project_root = detect_project_root_for_dashboard();
254 let index = crate::core::graph_index::load_or_build(&project_root);
255 let entries = build_heatmap_json(&index);
256 ("200 OK", "application/json", entries)
257 }
258 "/api/events" => {
259 let evs = crate::core::events::load_events_from_file(200);
260 let json = serde_json::to_string(&evs).unwrap_or_else(|_| "[]".to_string());
261 ("200 OK", "application/json", json)
262 }
263 "/api/graph" => {
264 let root = detect_project_root_for_dashboard();
265 let index = crate::core::graph_index::load_or_build(&root);
266 let json = serde_json::to_string(&index).unwrap_or_else(|_| {
267 "{\"error\":\"failed to serialize project index\"}".to_string()
268 });
269 ("200 OK", "application/json", json)
270 }
271 "/api/feedback" => {
272 let store = crate::core::feedback::FeedbackStore::load();
273 let json = serde_json::to_string(&store).unwrap_or_else(|_| {
274 "{\"error\":\"failed to serialize feedback store\"}".to_string()
275 });
276 ("200 OK", "application/json", json)
277 }
278 "/api/session" => {
279 let session = crate::core::session::SessionState::load_latest().unwrap_or_default();
280 let json = serde_json::to_string(&session)
281 .unwrap_or_else(|_| "{\"error\":\"failed to serialize session\"}".to_string());
282 ("200 OK", "application/json", json)
283 }
284 "/api/search-index" => {
285 let root_s = detect_project_root_for_dashboard();
286 let root = std::path::Path::new(&root_s);
287 let index = crate::core::vector_index::BM25Index::load_or_build(root);
288 let summary = bm25_index_summary_json(&index);
289 let json = serde_json::to_string(&summary).unwrap_or_else(|_| {
290 "{\"error\":\"failed to serialize search index summary\"}".to_string()
291 });
292 ("200 OK", "application/json", json)
293 }
294 "/api/compression-demo" => {
295 let body = match extract_query_param(query_str, "path") {
296 None => r#"{"error":"missing path query parameter"}"#.to_string(),
297 Some(rel) => {
298 let root = detect_project_root_for_dashboard();
299 let root_pb = std::path::Path::new(&root);
300 let candidate = std::path::Path::new(&rel);
301 let full = if candidate.is_absolute() {
302 candidate.to_path_buf()
303 } else {
304 let direct = root_pb.join(&rel);
305 if direct.exists() {
306 direct
307 } else {
308 let in_rust = root_pb.join("rust").join(&rel);
309 if in_rust.exists() {
310 in_rust
311 } else {
312 direct
313 }
314 }
315 };
316 match std::fs::read_to_string(&full) {
317 Ok(content) => {
318 let ext = full.extension().and_then(|e| e.to_str()).unwrap_or("rs");
319 let path_str = full.to_string_lossy().to_string();
320 let original_lines = content.lines().count();
321 let original_tokens = crate::core::tokens::count_tokens(&content);
322 let modes = compression_demo_modes_json(
323 &content,
324 &path_str,
325 ext,
326 original_tokens,
327 );
328 let original_preview: String = content.chars().take(8000).collect();
329 serde_json::json!({
330 "path": path_str,
331 "original_lines": original_lines,
332 "original_tokens": original_tokens,
333 "original": original_preview,
334 "modes": modes,
335 })
336 .to_string()
337 }
338 Err(_) => r#"{"error":"failed to read file"}"#.to_string(),
339 }
340 }
341 };
342 ("200 OK", "application/json", body)
343 }
344 "/" | "/index.html" => {
345 let mut html = DASHBOARD_HTML.to_string();
346 if let Some(ref tok) = query_token {
347 let script = format!(
348 "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
349 tok.replace('"', "")
350 );
351 html = html.replacen("<head>", &format!("<head>{script}"), 1);
352 } else if let Some(ref t) = token {
353 let script = format!(
354 "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
355 t.as_str()
356 );
357 html = html.replacen("<head>", &format!("<head>{script}"), 1);
358 }
359 ("200 OK", "text/html; charset=utf-8", html)
360 }
361 "/favicon.ico" => ("204 No Content", "text/plain", String::new()),
362 _ => ("404 Not Found", "text/plain", "Not Found".to_string()),
363 };
364
365 let cache_header = if content_type.starts_with("application/json") {
366 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
367 } else {
368 ""
369 };
370
371 let response = format!(
372 "HTTP/1.1 {status}\r\n\
373 Content-Type: {content_type}\r\n\
374 Content-Length: {}\r\n\
375 {cache_header}\
376 Access-Control-Allow-Origin: *\r\n\
377 Connection: close\r\n\
378 \r\n\
379 {body}",
380 body.len()
381 );
382
383 let _ = stream.write_all(response.as_bytes()).await;
384}
385
386fn check_auth(request: &str, expected_token: &str) -> bool {
387 for line in request.lines() {
388 let lower = line.to_lowercase();
389 if lower.starts_with("authorization:") {
390 let value = line["authorization:".len()..].trim();
391 if let Some(token) = value.strip_prefix("Bearer ") {
392 return token.trim() == expected_token;
393 }
394 if let Some(token) = value.strip_prefix("bearer ") {
395 return token.trim() == expected_token;
396 }
397 }
398 }
399 false
400}
401
402fn extract_query_param(qs: &str, key: &str) -> Option<String> {
403 for pair in qs.split('&') {
404 let (k, v) = match pair.split_once('=') {
405 Some(kv) => kv,
406 None => continue,
407 };
408 if k == key {
409 return Some(percent_decode_query_component(v));
410 }
411 }
412 None
413}
414
415fn percent_decode_query_component(s: &str) -> String {
416 let mut out: Vec<u8> = Vec::with_capacity(s.len());
417 let b = s.as_bytes();
418 let mut i = 0;
419 while i < b.len() {
420 match b[i] {
421 b'+' => {
422 out.push(b' ');
423 i += 1;
424 }
425 b'%' if i + 2 < b.len() => {
426 let h1 = (b[i + 1] as char).to_digit(16);
427 let h2 = (b[i + 2] as char).to_digit(16);
428 if let (Some(a), Some(d)) = (h1, h2) {
429 out.push(((a << 4) | d) as u8);
430 i += 3;
431 } else {
432 out.push(b'%');
433 i += 1;
434 }
435 }
436 _ => {
437 out.push(b[i]);
438 i += 1;
439 }
440 }
441 }
442 String::from_utf8_lossy(&out).into_owned()
443}
444
445fn compression_mode_json(output: &str, original_tokens: usize) -> serde_json::Value {
446 let tokens = crate::core::tokens::count_tokens(output);
447 let savings_pct = if original_tokens > 0 {
448 ((original_tokens.saturating_sub(tokens)) as f64 / original_tokens as f64 * 100.0).round()
449 as i64
450 } else {
451 0
452 };
453 serde_json::json!({
454 "output": output,
455 "tokens": tokens,
456 "savings_pct": savings_pct
457 })
458}
459
460fn compression_demo_modes_json(
461 content: &str,
462 path: &str,
463 ext: &str,
464 original_tokens: usize,
465) -> serde_json::Value {
466 let map_out = crate::core::signatures::extract_file_map(path, content);
467 let sig_out = crate::core::signatures::extract_signatures(content, ext)
468 .iter()
469 .map(|s| s.to_compact())
470 .collect::<Vec<_>>()
471 .join("\n");
472 let aggressive_out = crate::core::filters::aggressive_filter(content);
473 let entropy_out = crate::core::entropy::entropy_compress_adaptive(content, path).output;
474 serde_json::json!({
475 "map": compression_mode_json(&map_out, original_tokens),
476 "signatures": compression_mode_json(&sig_out, original_tokens),
477 "aggressive": compression_mode_json(&aggressive_out, original_tokens),
478 "entropy": compression_mode_json(&entropy_out, original_tokens),
479 })
480}
481
482fn bm25_index_summary_json(index: &crate::core::vector_index::BM25Index) -> serde_json::Value {
483 let mut sorted: Vec<&crate::core::vector_index::CodeChunk> = index.chunks.iter().collect();
484 sorted.sort_by_key(|c| std::cmp::Reverse(c.token_count));
485 let top: Vec<serde_json::Value> = sorted
486 .into_iter()
487 .take(20)
488 .map(|c| {
489 serde_json::json!({
490 "file_path": c.file_path,
491 "symbol_name": c.symbol_name,
492 "token_count": c.token_count,
493 "kind": c.kind,
494 "start_line": c.start_line,
495 "end_line": c.end_line,
496 })
497 })
498 .collect();
499 let mut lang: HashMap<String, usize> = HashMap::new();
500 for c in &index.chunks {
501 let e = std::path::Path::new(&c.file_path)
502 .extension()
503 .and_then(|e| e.to_str())
504 .unwrap_or("")
505 .to_string();
506 *lang.entry(e).or_default() += 1;
507 }
508 serde_json::json!({
509 "doc_count": index.doc_count,
510 "chunk_count": index.chunks.len(),
511 "top_chunks_by_token_count": top,
512 "language_distribution": lang,
513 })
514}
515
516fn build_heatmap_json(index: &crate::core::graph_index::ProjectIndex) -> String {
517 let mut connection_counts: std::collections::HashMap<String, usize> =
518 std::collections::HashMap::new();
519 for edge in &index.edges {
520 *connection_counts.entry(edge.from.clone()).or_default() += 1;
521 *connection_counts.entry(edge.to.clone()).or_default() += 1;
522 }
523
524 let max_tokens = index
525 .files
526 .values()
527 .map(|f| f.token_count)
528 .max()
529 .unwrap_or(1) as f64;
530 let max_connections = connection_counts.values().max().copied().unwrap_or(1) as f64;
531
532 let mut entries: Vec<serde_json::Value> = index
533 .files
534 .values()
535 .map(|f| {
536 let connections = connection_counts.get(&f.path).copied().unwrap_or(0);
537 let token_norm = f.token_count as f64 / max_tokens;
538 let conn_norm = connections as f64 / max_connections;
539 let heat = token_norm * 0.4 + conn_norm * 0.6;
540 serde_json::json!({
541 "path": f.path,
542 "tokens": f.token_count,
543 "connections": connections,
544 "language": f.language,
545 "heat": (heat * 100.0).round() / 100.0,
546 })
547 })
548 .collect();
549
550 entries.sort_by(|a, b| {
551 b["heat"]
552 .as_f64()
553 .unwrap_or(0.0)
554 .partial_cmp(&a["heat"].as_f64().unwrap_or(0.0))
555 .unwrap()
556 });
557
558 serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
559}
560
561fn build_agents_json() -> String {
562 let registry = crate::core::agents::AgentRegistry::load_or_create();
563 let agents: Vec<serde_json::Value> = registry
564 .agents
565 .iter()
566 .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
567 .map(|a| {
568 let age_min = (chrono::Utc::now() - a.last_active).num_minutes();
569 serde_json::json!({
570 "id": a.agent_id,
571 "type": a.agent_type,
572 "role": a.role,
573 "status": format!("{}", a.status),
574 "status_message": a.status_message,
575 "last_active_minutes_ago": age_min,
576 "pid": a.pid
577 })
578 })
579 .collect();
580
581 let pending_msgs = registry.scratchpad.len();
582
583 let shared_dir = dirs::home_dir()
584 .unwrap_or_default()
585 .join(".lean-ctx")
586 .join("agents")
587 .join("shared");
588 let shared_count = if shared_dir.exists() {
589 std::fs::read_dir(&shared_dir)
590 .map(|rd| rd.count())
591 .unwrap_or(0)
592 } else {
593 0
594 };
595
596 serde_json::json!({
597 "agents": agents,
598 "total_active": agents.len(),
599 "pending_messages": pending_msgs,
600 "shared_contexts": shared_count
601 })
602 .to_string()
603}
604
605fn detect_project_root_for_dashboard() -> String {
606 if let Some(session) = crate::core::session::SessionState::load_latest() {
609 if let Some(root) = session.project_root.as_deref() {
610 if !root.trim().is_empty() {
611 return promote_to_git_root(root);
612 }
613 }
614 if let Some(cwd) = session.shell_cwd.as_deref() {
615 if !cwd.trim().is_empty() {
616 let r = crate::core::protocol::detect_project_root_or_cwd(cwd);
617 return promote_to_git_root(&r);
618 }
619 }
620 if let Some(last) = session.files_touched.last() {
621 if !last.path.trim().is_empty() {
622 if let Some(parent) = Path::new(&last.path).parent() {
623 let p = parent.to_string_lossy().to_string();
624 let r = crate::core::protocol::detect_project_root_or_cwd(&p);
625 return promote_to_git_root(&r);
626 }
627 }
628 }
629 }
630
631 let cwd = std::env::current_dir()
632 .map(|p| p.to_string_lossy().to_string())
633 .unwrap_or_else(|_| ".".to_string());
634 let r = crate::core::protocol::detect_project_root_or_cwd(&cwd);
635 promote_to_git_root(&r)
636}
637
638fn promote_to_git_root(path: &str) -> String {
639 git_root_for(path).unwrap_or_else(|| path.to_string())
640}
641
642fn git_root_for(path: &str) -> Option<String> {
643 let mut p = Path::new(path);
644 loop {
645 let git = p.join(".git");
646 if git.exists() {
647 return Some(p.to_string_lossy().to_string());
648 }
649 p = p.parent()?;
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn check_auth_with_valid_bearer() {
659 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
660 assert!(check_auth(req, "lctx_abc123"));
661 }
662
663 #[test]
664 fn check_auth_with_invalid_bearer() {
665 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
666 assert!(!check_auth(req, "lctx_abc123"));
667 }
668
669 #[test]
670 fn check_auth_missing_header() {
671 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
672 assert!(!check_auth(req, "lctx_abc123"));
673 }
674
675 #[test]
676 fn check_auth_lowercase_bearer() {
677 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
678 assert!(check_auth(req, "lctx_abc123"));
679 }
680
681 #[test]
682 fn query_token_parsing() {
683 let raw_path = "/index.html?token=lctx_abc123&other=val";
684 let idx = raw_path.find('?').unwrap();
685 let qs = &raw_path[idx + 1..];
686 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
687 assert_eq!(tok, Some("lctx_abc123"));
688 }
689
690 #[test]
691 fn api_path_detection() {
692 assert!("/api/stats".starts_with("/api/"));
693 assert!("/api/version".starts_with("/api/"));
694 assert!(!"/".starts_with("/api/"));
695 assert!(!"/index.html".starts_with("/api/"));
696 assert!(!"/favicon.ico".starts_with("/api/"));
697 }
698}