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        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        // Try project_root first, but only if it resolves to a real project (has .git or markers).
924        // MCP sessions often set project_root to a temp sandbox directory that contains no code.
925        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}