Skip to main content

lean_ctx/dashboard/
mod.rs

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    // Avoid accidental multiple dashboard instances (common source of "it hangs").
29    // Only safe to auto-detect for local dashboards without auth.
30    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/heatmap" => {
309            let project_root = detect_project_root_for_dashboard();
310            let index = crate::core::graph_index::load_or_build(&project_root);
311            let entries = build_heatmap_json(&index);
312            ("200 OK", "application/json", entries)
313        }
314        "/api/events" => {
315            let evs = crate::core::events::load_events_from_file(200);
316            let json = serde_json::to_string(&evs).unwrap_or_else(|_| "[]".to_string());
317            ("200 OK", "application/json", json)
318        }
319        "/api/graph" => {
320            let root = detect_project_root_for_dashboard();
321            let index = crate::core::graph_index::load_or_build(&root);
322            let json = serde_json::to_string(&index).unwrap_or_else(|_| {
323                "{\"error\":\"failed to serialize project index\"}".to_string()
324            });
325            ("200 OK", "application/json", json)
326        }
327        "/api/call-graph" => {
328            let root = detect_project_root_for_dashboard();
329            let index = crate::core::graph_index::load_or_build(&root);
330            let call_graph = crate::core::call_graph::CallGraph::load_or_build(&root, &index);
331            let _ = call_graph.save();
332            let json = serde_json::to_string(&call_graph)
333                .unwrap_or_else(|_| "{\"error\":\"failed to serialize call graph\"}".to_string());
334            ("200 OK", "application/json", json)
335        }
336        "/api/feedback" => {
337            let store = crate::core::feedback::FeedbackStore::load();
338            let json = serde_json::to_string(&store).unwrap_or_else(|_| {
339                "{\"error\":\"failed to serialize feedback store\"}".to_string()
340            });
341            ("200 OK", "application/json", json)
342        }
343        "/api/symbols" => {
344            let root = detect_project_root_for_dashboard();
345            let index = crate::core::graph_index::load_or_build(&root);
346            let q = extract_query_param(query_str, "q");
347            let kind = extract_query_param(query_str, "kind");
348            let json = build_symbols_json(&index, q.as_deref(), kind.as_deref());
349            ("200 OK", "application/json", json)
350        }
351        "/api/routes" => {
352            let root = detect_project_root_for_dashboard();
353            let index = crate::core::graph_index::load_or_build(&root);
354            let routes =
355                crate::core::route_extractor::extract_routes_from_project(&root, &index.files);
356            let json = serde_json::to_string(&routes).unwrap_or_else(|_| "[]".to_string());
357            ("200 OK", "application/json", json)
358        }
359        "/api/session" => {
360            let session = crate::core::session::SessionState::load_latest().unwrap_or_default();
361            let json = serde_json::to_string(&session)
362                .unwrap_or_else(|_| "{\"error\":\"failed to serialize session\"}".to_string());
363            ("200 OK", "application/json", json)
364        }
365        "/api/search-index" => {
366            let root_s = detect_project_root_for_dashboard();
367            let root = std::path::Path::new(&root_s);
368            let index = crate::core::vector_index::BM25Index::load_or_build(root);
369            let summary = bm25_index_summary_json(&index);
370            let json = serde_json::to_string(&summary).unwrap_or_else(|_| {
371                "{\"error\":\"failed to serialize search index summary\"}".to_string()
372            });
373            ("200 OK", "application/json", json)
374        }
375        "/api/search" => {
376            let q = extract_query_param(query_str, "q").unwrap_or_default();
377            let limit: usize = extract_query_param(query_str, "limit")
378                .and_then(|l| l.parse().ok())
379                .unwrap_or(20);
380            if q.trim().is_empty() {
381                (
382                    "200 OK",
383                    "application/json",
384                    r#"{"results":[]}"#.to_string(),
385                )
386            } else {
387                let root_s = detect_project_root_for_dashboard();
388                let root = std::path::Path::new(&root_s);
389                let index = crate::core::vector_index::BM25Index::load_or_build(root);
390                let hits = index.search(&q, limit);
391                let results: Vec<serde_json::Value> = hits
392                    .iter()
393                    .map(|r| {
394                        serde_json::json!({
395                            "score": (r.score * 100.0).round() / 100.0,
396                            "file_path": r.file_path,
397                            "symbol_name": r.symbol_name,
398                            "kind": r.kind,
399                            "start_line": r.start_line,
400                            "end_line": r.end_line,
401                            "snippet": r.snippet,
402                        })
403                    })
404                    .collect();
405                let json = serde_json::json!({ "results": results }).to_string();
406                ("200 OK", "application/json", json)
407            }
408        }
409        "/api/compression-demo" => {
410            let body = match extract_query_param(query_str, "path") {
411                None => r#"{"error":"missing path query parameter"}"#.to_string(),
412                Some(rel) => {
413                    let task = extract_query_param(query_str, "task");
414                    let root = detect_project_root_for_dashboard();
415                    let root_pb = std::path::Path::new(&root);
416                    let rel = normalize_dashboard_demo_path(&rel);
417                    let candidate = std::path::Path::new(&rel);
418                    let full = if candidate.is_absolute() {
419                        candidate.to_path_buf()
420                    } else {
421                        let direct = root_pb.join(&rel);
422                        if direct.exists() {
423                            direct
424                        } else {
425                            let in_rust = root_pb.join("rust").join(&rel);
426                            if in_rust.exists() {
427                                in_rust
428                            } else {
429                                direct
430                            }
431                        }
432                    };
433                    match std::fs::read_to_string(&full) {
434                        Ok(content) => {
435                            let ext = full.extension().and_then(|e| e.to_str()).unwrap_or("rs");
436                            let path_str = full.to_string_lossy().to_string();
437                            let original_lines = content.lines().count();
438                            let original_tokens = crate::core::tokens::count_tokens(&content);
439                            let modes = compression_demo_modes_json(
440                                &content,
441                                &path_str,
442                                ext,
443                                original_tokens,
444                                task.as_deref(),
445                            );
446                            let original_preview: String = content.chars().take(8000).collect();
447                            serde_json::json!({
448                                "path": path_str,
449                                "task": task,
450                                "original_lines": original_lines,
451                                "original_tokens": original_tokens,
452                                "original": original_preview,
453                                "modes": modes,
454                            })
455                            .to_string()
456                        }
457                        Err(_) => r#"{"error":"failed to read file"}"#.to_string(),
458                    }
459                }
460            };
461            ("200 OK", "application/json", body)
462        }
463        "/" | "/index.html" => {
464            let mut html = DASHBOARD_HTML.to_string();
465            if let Some(ref tok) = query_token {
466                let script = format!(
467                    "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
468                    tok.replace('"', "")
469                );
470                html = html.replacen("<head>", &format!("<head>{script}"), 1);
471            } else if let Some(ref t) = token {
472                let script = format!(
473                    "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
474                    t.as_str()
475                );
476                html = html.replacen("<head>", &format!("<head>{script}"), 1);
477            }
478            ("200 OK", "text/html; charset=utf-8", html)
479        }
480        "/favicon.ico" => ("204 No Content", "text/plain", String::new()),
481        _ => ("404 Not Found", "text/plain", "Not Found".to_string()),
482    }
483}
484
485fn check_auth(request: &str, expected_token: &str) -> bool {
486    for line in request.lines() {
487        let lower = line.to_lowercase();
488        if lower.starts_with("authorization:") {
489            let value = line["authorization:".len()..].trim();
490            if let Some(token) = value.strip_prefix("Bearer ") {
491                return token.trim() == expected_token;
492            }
493            if let Some(token) = value.strip_prefix("bearer ") {
494                return token.trim() == expected_token;
495            }
496        }
497    }
498    false
499}
500
501fn extract_query_param(qs: &str, key: &str) -> Option<String> {
502    for pair in qs.split('&') {
503        let (k, v) = match pair.split_once('=') {
504            Some(kv) => kv,
505            None => continue,
506        };
507        if k == key {
508            return Some(percent_decode_query_component(v));
509        }
510    }
511    None
512}
513
514fn percent_decode_query_component(s: &str) -> String {
515    let mut out: Vec<u8> = Vec::with_capacity(s.len());
516    let b = s.as_bytes();
517    let mut i = 0;
518    while i < b.len() {
519        match b[i] {
520            b'+' => {
521                out.push(b' ');
522                i += 1;
523            }
524            b'%' if i + 2 < b.len() => {
525                let h1 = (b[i + 1] as char).to_digit(16);
526                let h2 = (b[i + 2] as char).to_digit(16);
527                if let (Some(a), Some(d)) = (h1, h2) {
528                    out.push(((a << 4) | d) as u8);
529                    i += 3;
530                } else {
531                    out.push(b'%');
532                    i += 1;
533                }
534            }
535            _ => {
536                out.push(b[i]);
537                i += 1;
538            }
539        }
540    }
541    String::from_utf8_lossy(&out).into_owned()
542}
543
544fn normalize_dashboard_demo_path(path: &str) -> String {
545    let trimmed = path.trim();
546    if trimmed.is_empty() {
547        return String::new();
548    }
549
550    let candidate = Path::new(trimmed);
551    if candidate.is_absolute() || is_windows_absolute_path(trimmed) {
552        return trimmed.to_string();
553    }
554
555    trimmed
556        .trim_start_matches(['\\', '/'])
557        .replace('\\', std::path::MAIN_SEPARATOR_STR)
558}
559
560fn is_windows_absolute_path(path: &str) -> bool {
561    let bytes = path.as_bytes();
562    if bytes.len() >= 3
563        && bytes[0].is_ascii_alphabetic()
564        && bytes[1] == b':'
565        && matches!(bytes[2], b'\\' | b'/')
566    {
567        return true;
568    }
569
570    path.starts_with("\\\\") || path.starts_with("//")
571}
572
573fn compression_mode_json(output: &str, original_tokens: usize) -> serde_json::Value {
574    let tokens = crate::core::tokens::count_tokens(output);
575    let savings_pct = if original_tokens > 0 {
576        ((original_tokens.saturating_sub(tokens)) as f64 / original_tokens as f64 * 100.0).round()
577            as i64
578    } else {
579        0
580    };
581    serde_json::json!({
582        "output": output,
583        "tokens": tokens,
584        "savings_pct": savings_pct
585    })
586}
587
588fn compression_demo_modes_json(
589    content: &str,
590    path: &str,
591    ext: &str,
592    original_tokens: usize,
593    task: Option<&str>,
594) -> serde_json::Value {
595    let map_out = crate::core::signatures::extract_file_map(path, content);
596    let sig_out = crate::core::signatures::extract_signatures(content, ext)
597        .iter()
598        .map(|s| s.to_compact())
599        .collect::<Vec<_>>()
600        .join("\n");
601    let aggressive_out = crate::core::filters::aggressive_filter(content);
602    let entropy_out = crate::core::entropy::entropy_compress_adaptive(content, path).output;
603
604    let mut cache = crate::core::cache::SessionCache::new();
605    let reference_out =
606        crate::tools::ctx_read::handle(&mut cache, path, "reference", crate::tools::CrpMode::Off);
607    let task_out = task.filter(|t| !t.trim().is_empty()).map(|t| {
608        crate::tools::ctx_read::handle_with_task(
609            &mut cache,
610            path,
611            "task",
612            crate::tools::CrpMode::Off,
613            Some(t),
614        )
615    });
616
617    serde_json::json!({
618        "map": compression_mode_json(&map_out, original_tokens),
619        "signatures": compression_mode_json(&sig_out, original_tokens),
620        "reference": compression_mode_json(&reference_out, original_tokens),
621        "aggressive": compression_mode_json(&aggressive_out, original_tokens),
622        "entropy": compression_mode_json(&entropy_out, original_tokens),
623        "task": task_out.as_deref().map(|s| compression_mode_json(s, original_tokens)).unwrap_or(serde_json::Value::Null),
624    })
625}
626
627fn bm25_index_summary_json(index: &crate::core::vector_index::BM25Index) -> serde_json::Value {
628    let mut sorted: Vec<&crate::core::vector_index::CodeChunk> = index.chunks.iter().collect();
629    sorted.sort_by_key(|c| std::cmp::Reverse(c.token_count));
630    let top: Vec<serde_json::Value> = sorted
631        .into_iter()
632        .take(20)
633        .map(|c| {
634            serde_json::json!({
635                "file_path": c.file_path,
636                "symbol_name": c.symbol_name,
637                "token_count": c.token_count,
638                "kind": c.kind,
639                "start_line": c.start_line,
640                "end_line": c.end_line,
641            })
642        })
643        .collect();
644    let mut lang: HashMap<String, usize> = HashMap::new();
645    for c in &index.chunks {
646        let e = std::path::Path::new(&c.file_path)
647            .extension()
648            .and_then(|e| e.to_str())
649            .unwrap_or("")
650            .to_string();
651        *lang.entry(e).or_default() += 1;
652    }
653    serde_json::json!({
654        "doc_count": index.doc_count,
655        "chunk_count": index.chunks.len(),
656        "top_chunks_by_token_count": top,
657        "language_distribution": lang,
658    })
659}
660
661fn build_symbols_json(
662    index: &crate::core::graph_index::ProjectIndex,
663    query: Option<&str>,
664    kind: Option<&str>,
665) -> String {
666    let query = query
667        .map(|q| q.trim().to_lowercase())
668        .filter(|q| !q.is_empty());
669    let kind = kind
670        .map(|k| k.trim().to_lowercase())
671        .filter(|k| !k.is_empty());
672
673    let mut symbols: Vec<&crate::core::graph_index::SymbolEntry> = index
674        .symbols
675        .values()
676        .filter(|sym| {
677            let kind_match = match kind.as_ref() {
678                Some(k) => sym.kind.eq_ignore_ascii_case(k),
679                None => true,
680            };
681            let query_match = match query.as_ref() {
682                Some(q) => {
683                    let name = sym.name.to_lowercase();
684                    let file = sym.file.to_lowercase();
685                    let symbol_kind = sym.kind.to_lowercase();
686                    name.contains(q) || file.contains(q) || symbol_kind.contains(q)
687                }
688                None => true,
689            };
690            kind_match && query_match
691        })
692        .collect();
693
694    symbols.sort_by(|a, b| {
695        a.file
696            .cmp(&b.file)
697            .then_with(|| a.start_line.cmp(&b.start_line))
698            .then_with(|| a.name.cmp(&b.name))
699    });
700    symbols.truncate(500);
701
702    serde_json::to_string(
703        &symbols
704            .into_iter()
705            .map(|sym| {
706                serde_json::json!({
707                    "name": sym.name,
708                    "kind": sym.kind,
709                    "file": sym.file,
710                    "start_line": sym.start_line,
711                    "end_line": sym.end_line,
712                    "is_exported": sym.is_exported,
713                })
714            })
715            .collect::<Vec<_>>(),
716    )
717    .unwrap_or_else(|_| "[]".to_string())
718}
719
720fn build_heatmap_json(index: &crate::core::graph_index::ProjectIndex) -> String {
721    let mut connection_counts: std::collections::HashMap<String, usize> =
722        std::collections::HashMap::new();
723    for edge in &index.edges {
724        *connection_counts.entry(edge.from.clone()).or_default() += 1;
725        *connection_counts.entry(edge.to.clone()).or_default() += 1;
726    }
727
728    let max_tokens = index
729        .files
730        .values()
731        .map(|f| f.token_count)
732        .max()
733        .unwrap_or(1) as f64;
734    let max_connections = connection_counts.values().max().copied().unwrap_or(1) as f64;
735
736    let mut entries: Vec<serde_json::Value> = index
737        .files
738        .values()
739        .map(|f| {
740            let connections = connection_counts.get(&f.path).copied().unwrap_or(0);
741            let token_norm = f.token_count as f64 / max_tokens;
742            let conn_norm = connections as f64 / max_connections;
743            let heat = token_norm * 0.4 + conn_norm * 0.6;
744            serde_json::json!({
745                "path": f.path,
746                "tokens": f.token_count,
747                "connections": connections,
748                "language": f.language,
749                "heat": (heat * 100.0).round() / 100.0,
750            })
751        })
752        .collect();
753
754    entries.sort_by(|a, b| {
755        b["heat"]
756            .as_f64()
757            .unwrap_or(0.0)
758            .partial_cmp(&a["heat"].as_f64().unwrap_or(0.0))
759            .unwrap()
760    });
761
762    serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
763}
764
765fn build_agents_json() -> String {
766    let registry = crate::core::agents::AgentRegistry::load_or_create();
767    let agents: Vec<serde_json::Value> = registry
768        .agents
769        .iter()
770        .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
771        .map(|a| {
772            let age_min = (chrono::Utc::now() - a.last_active).num_minutes();
773            serde_json::json!({
774                "id": a.agent_id,
775                "type": a.agent_type,
776                "role": a.role,
777                "status": format!("{}", a.status),
778                "status_message": a.status_message,
779                "last_active_minutes_ago": age_min,
780                "pid": a.pid
781            })
782        })
783        .collect();
784
785    let pending_msgs = registry.scratchpad.len();
786
787    let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
788        .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".lean-ctx"))
789        .join("agents")
790        .join("shared");
791    let shared_count = if shared_dir.exists() {
792        std::fs::read_dir(&shared_dir)
793            .map(|rd| rd.count())
794            .unwrap_or(0)
795    } else {
796        0
797    };
798
799    serde_json::json!({
800        "agents": agents,
801        "total_active": agents.len(),
802        "pending_messages": pending_msgs,
803        "shared_contexts": shared_count
804    })
805    .to_string()
806}
807
808fn detect_project_root_for_dashboard() -> String {
809    // Prefer last known project context from the persisted session. This makes the dashboard
810    // show the same project data even if it is launched from an arbitrary working directory.
811    if let Some(session) = crate::core::session::SessionState::load_latest() {
812        if let Some(root) = session.project_root.as_deref() {
813            if !root.trim().is_empty() {
814                return promote_to_git_root(root);
815            }
816        }
817        if let Some(cwd) = session.shell_cwd.as_deref() {
818            if !cwd.trim().is_empty() {
819                let r = crate::core::protocol::detect_project_root_or_cwd(cwd);
820                return promote_to_git_root(&r);
821            }
822        }
823        if let Some(last) = session.files_touched.last() {
824            if !last.path.trim().is_empty() {
825                if let Some(parent) = Path::new(&last.path).parent() {
826                    let p = parent.to_string_lossy().to_string();
827                    let r = crate::core::protocol::detect_project_root_or_cwd(&p);
828                    return promote_to_git_root(&r);
829                }
830            }
831        }
832    }
833
834    let cwd = std::env::current_dir()
835        .map(|p| p.to_string_lossy().to_string())
836        .unwrap_or_else(|_| ".".to_string());
837    let r = crate::core::protocol::detect_project_root_or_cwd(&cwd);
838    promote_to_git_root(&r)
839}
840
841fn promote_to_git_root(path: &str) -> String {
842    git_root_for(path).unwrap_or_else(|| path.to_string())
843}
844
845fn git_root_for(path: &str) -> Option<String> {
846    let mut p = Path::new(path);
847    loop {
848        let git = p.join(".git");
849        if git.exists() {
850            return Some(p.to_string_lossy().to_string());
851        }
852        p = p.parent()?;
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859
860    #[test]
861    fn check_auth_with_valid_bearer() {
862        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
863        assert!(check_auth(req, "lctx_abc123"));
864    }
865
866    #[test]
867    fn check_auth_with_invalid_bearer() {
868        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
869        assert!(!check_auth(req, "lctx_abc123"));
870    }
871
872    #[test]
873    fn check_auth_missing_header() {
874        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
875        assert!(!check_auth(req, "lctx_abc123"));
876    }
877
878    #[test]
879    fn check_auth_lowercase_bearer() {
880        let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
881        assert!(check_auth(req, "lctx_abc123"));
882    }
883
884    #[test]
885    fn query_token_parsing() {
886        let raw_path = "/index.html?token=lctx_abc123&other=val";
887        let idx = raw_path.find('?').unwrap();
888        let qs = &raw_path[idx + 1..];
889        let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
890        assert_eq!(tok, Some("lctx_abc123"));
891    }
892
893    #[test]
894    fn api_path_detection() {
895        assert!("/api/stats".starts_with("/api/"));
896        assert!("/api/version".starts_with("/api/"));
897        assert!(!"/".starts_with("/api/"));
898        assert!(!"/index.html".starts_with("/api/"));
899        assert!(!"/favicon.ico".starts_with("/api/"));
900    }
901
902    #[test]
903    fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
904        let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
905        assert_eq!(
906            normalized,
907            format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
908        );
909    }
910
911    #[test]
912    fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
913        let input = r"C:\repo\backend\list_tables.js";
914        assert_eq!(normalize_dashboard_demo_path(input), input);
915    }
916
917    #[test]
918    fn normalize_dashboard_demo_path_preserves_unc_path() {
919        let input = r"\\server\share\backend\list_tables.js";
920        assert_eq!(normalize_dashboard_demo_path(input), input);
921    }
922}