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    // Always enable auth (even on loopback) to prevent cross-origin reads of /api/*
38    // from a malicious website (CORS is not a reliable boundary for localhost services).
39    let t = generate_token();
40    save_token(&t);
41    let token = Some(Arc::new(t));
42
43    if let Some(t) = token.as_ref() {
44        if is_local {
45            println!("  Auth: enabled (local)");
46            println!("  Browser URL:  http://localhost:{port}/?token={t}");
47        } else {
48            eprintln!(
49                "  \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n  \
50                 Bearer token: \x1b[1;32m{t}\x1b[0m\n  \
51                 Browser URL:  http://<your-ip>:{port}/?token={t}"
52            );
53        }
54    }
55
56    let listener = match TcpListener::bind(&addr).await {
57        Ok(l) => l,
58        Err(e) => {
59            eprintln!("Failed to bind to {addr}: {e}");
60            std::process::exit(1);
61        }
62    };
63
64    let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
65        |_| "~/.lean-ctx/stats.json".to_string(),
66        |d| d.join("stats.json").display().to_string(),
67    );
68
69    if host == "0.0.0.0" {
70        println!("\n  lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
71        println!("  Local access:  http://localhost:{port}");
72    } else {
73        println!("\n  lean-ctx dashboard → http://{host}:{port}");
74    }
75    println!("  Stats file: {stats_path}");
76    println!("  Press Ctrl+C to stop\n");
77
78    if is_local {
79        if let Some(t) = token.as_ref() {
80            open_browser(&format!("http://localhost:{port}/?token={t}"));
81        } else {
82            open_browser(&format!("http://localhost:{port}"));
83        }
84    }
85    if crate::shell::is_container() && is_local {
86        println!("  Tip (Docker): bind 0.0.0.0 + publish port:");
87        println!("    lean-ctx dashboard --host=0.0.0.0 --port={port}");
88        println!("    docker run ... -p {port}:{port} ...");
89        println!();
90    }
91
92    loop {
93        if let Ok((stream, _)) = listener.accept().await {
94            let token_ref = token.clone();
95            tokio::spawn(handle_request(stream, token_ref));
96        }
97    }
98}
99
100fn generate_token() -> String {
101    let mut bytes = [0u8; 32];
102    let _ = getrandom::fill(&mut bytes);
103    format!("lctx_{}", hex_lower(&bytes))
104}
105
106fn save_token(token: &str) {
107    if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
108        let _ = std::fs::create_dir_all(&dir);
109        let path = dir.join("dashboard.token");
110        let _ = std::fs::write(&path, token);
111        #[cfg(unix)]
112        {
113            use std::os::unix::fs::PermissionsExt;
114            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
115        }
116    }
117}
118
119fn hex_lower(bytes: &[u8]) -> String {
120    const HEX: &[u8; 16] = b"0123456789abcdef";
121    let mut out = String::with_capacity(bytes.len() * 2);
122    for &b in bytes {
123        out.push(HEX[(b >> 4) as usize] as char);
124        out.push(HEX[(b & 0x0f) as usize] as char);
125    }
126    out
127}
128
129fn open_browser(url: &str) {
130    #[cfg(target_os = "macos")]
131    {
132        let _ = std::process::Command::new("open").arg(url).spawn();
133    }
134
135    #[cfg(target_os = "linux")]
136    {
137        let _ = std::process::Command::new("xdg-open")
138            .arg(url)
139            .stderr(std::process::Stdio::null())
140            .spawn();
141    }
142
143    #[cfg(target_os = "windows")]
144    {
145        let _ = std::process::Command::new("cmd")
146            .args(["/C", "start", url])
147            .spawn();
148    }
149}
150
151fn dashboard_responding(host: &str, port: u16) -> bool {
152    use std::io::{Read, Write};
153    use std::net::TcpStream;
154    use std::time::Duration;
155
156    let addr = format!("{host}:{port}");
157    let Ok(mut s) = TcpStream::connect_timeout(
158        &addr
159            .parse()
160            .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
161        Duration::from_millis(150),
162    ) else {
163        return false;
164    };
165    let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
166    let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
167
168    let req = "GET /api/version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
169    if s.write_all(req.as_bytes()).is_err() {
170        return false;
171    }
172    let mut buf = [0u8; 256];
173    let Ok(n) = s.read(&mut buf) else {
174        return false;
175    };
176    let head = String::from_utf8_lossy(&buf[..n]);
177    head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
178}
179
180async fn handle_request(mut stream: tokio::net::TcpStream, token: Option<Arc<String>>) {
181    let mut buf = vec![0u8; 4096];
182    let n = match stream.read(&mut buf).await {
183        Ok(n) if n > 0 => n,
184        _ => return,
185    };
186
187    let request = String::from_utf8_lossy(&buf[..n]);
188
189    let raw_path = request
190        .lines()
191        .next()
192        .and_then(|line| line.split_whitespace().nth(1))
193        .unwrap_or("/");
194
195    let (path, query_token) = if let Some(idx) = raw_path.find('?') {
196        let p = &raw_path[..idx];
197        let qs = &raw_path[idx + 1..];
198        let tok = qs
199            .split('&')
200            .find_map(|pair| pair.strip_prefix("token="))
201            .map(std::string::ToString::to_string);
202        (p.to_string(), tok)
203    } else {
204        (raw_path.to_string(), None)
205    };
206
207    let query_str = raw_path.find('?').map_or("", |i| &raw_path[i + 1..]);
208
209    let is_api = path.starts_with("/api/");
210    let requires_auth = is_api || path == "/metrics";
211
212    if let Some(ref expected) = token {
213        let has_header_auth = check_auth(&request, expected);
214
215        if requires_auth && !has_header_auth {
216            let body = r#"{"error":"unauthorized"}"#;
217            let response = format!(
218                "HTTP/1.1 401 Unauthorized\r\n\
219                 Content-Type: application/json\r\n\
220                 Content-Length: {}\r\n\
221                 WWW-Authenticate: Bearer\r\n\
222                 Connection: close\r\n\
223                 \r\n\
224                 {body}",
225                body.len()
226            );
227            let _ = stream.write_all(response.as_bytes()).await;
228            return;
229        }
230    }
231
232    let path = path.as_str();
233
234    let compute = std::panic::catch_unwind(|| {
235        route_response(path, query_str, query_token.as_ref(), token.as_ref())
236    });
237    let (status, content_type, body) = match compute {
238        Ok(v) => v,
239        Err(_) => (
240            "500 Internal Server Error",
241            "application/json",
242            r#"{"error":"dashboard route panicked"}"#.to_string(),
243        ),
244    };
245
246    let cache_header = if content_type.starts_with("application/json") {
247        "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
248    } else {
249        ""
250    };
251
252    let response = format!(
253        "HTTP/1.1 {status}\r\n\
254         Content-Type: {content_type}\r\n\
255         Content-Length: {}\r\n\
256         {cache_header}\
257         Connection: close\r\n\
258         \r\n\
259         {body}",
260        body.len()
261    );
262
263    let _ = stream.write_all(response.as_bytes()).await;
264}
265
266fn route_response(
267    path: &str,
268    query_str: &str,
269    query_token: Option<&String>,
270    token: Option<&Arc<String>>,
271) -> (&'static str, &'static str, String) {
272    match path {
273        "/api/stats" => {
274            let store = crate::core::stats::load();
275            let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
276            ("200 OK", "application/json", json)
277        }
278        "/api/gain" => {
279            let env_model = std::env::var("LEAN_CTX_MODEL")
280                .or_else(|_| std::env::var("LCTX_MODEL"))
281                .ok();
282            let engine = crate::core::gain::GainEngine::load();
283            let payload = serde_json::json!({
284                "summary": engine.summary(env_model.as_deref()),
285                "tasks": engine.task_breakdown(),
286                "heatmap": engine.heatmap_gains(20),
287            });
288            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
289            ("200 OK", "application/json", json)
290        }
291        "/api/mcp" => {
292            let mcp_path = crate::core::data_dir::lean_ctx_data_dir()
293                .map(|d| d.join("mcp-live.json"))
294                .unwrap_or_default();
295            let json = std::fs::read_to_string(&mcp_path).unwrap_or_else(|_| "{}".to_string());
296            ("200 OK", "application/json", json)
297        }
298        "/api/agents" => {
299            let json = build_agents_json();
300            ("200 OK", "application/json", json)
301        }
302        "/api/profile" => {
303            let active_name = crate::core::profiles::active_profile_name();
304            let profile = crate::core::profiles::active_profile();
305            let all = crate::core::profiles::list_profiles();
306            let active_info = all.iter().find(|p| p.name == active_name);
307            let available: Vec<serde_json::Value> = all
308                .iter()
309                .map(|p| {
310                    serde_json::json!({
311                        "name": p.name,
312                        "description": p.description,
313                        "source": p.source.to_string(),
314                    })
315                })
316                .collect();
317            let payload = serde_json::json!({
318                "active_name": active_name,
319                "active_source": active_info.map(|i| i.source.to_string()),
320                "active_description": active_info.map(|i| i.description.clone()),
321                "profile": profile,
322                "available": available,
323            });
324            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
325            ("200 OK", "application/json", json)
326        }
327        "/api/knowledge" => {
328            let project_root = detect_project_root_for_dashboard();
329            let policy = crate::core::config::Config::load()
330                .memory_policy_effective()
331                .unwrap_or_default();
332            let _ = crate::core::knowledge::ProjectKnowledge::migrate_legacy_empty_root(
333                &project_root,
334                &policy,
335            );
336
337            let mut knowledge =
338                crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
339            if knowledge.facts.is_empty() {
340                // Keep /api/knowledge fast: avoid forcing a full index build here.
341                let idx = crate::core::graph_index::ProjectIndex::load(&project_root);
342                if crate::core::knowledge_bootstrap::bootstrap_if_empty(
343                    &mut knowledge,
344                    &project_root,
345                    idx.as_ref(),
346                    &policy,
347                ) {
348                    let _ = knowledge.save();
349                }
350            }
351            let json = serde_json::to_string(&knowledge).unwrap_or_else(|_| "{}".to_string());
352            ("200 OK", "application/json", json)
353        }
354        "/api/knowledge-relations" => {
355            let project_root = detect_project_root_for_dashboard();
356            let policy = crate::core::config::Config::load()
357                .memory_policy_effective()
358                .unwrap_or_default();
359
360            let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
361            let graph = crate::core::knowledge_relations::KnowledgeRelationGraph::load_or_create(
362                &knowledge.project_hash,
363            );
364
365            let current_ids: std::collections::HashSet<String> = knowledge
366                .facts
367                .iter()
368                .filter(|f| f.is_current())
369                .map(|f| format!("{}/{}", f.category, f.key))
370                .collect();
371
372            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
373            let mut edges: Vec<serde_json::Value> = Vec::new();
374
375            let mut push_edge = |from: String, to: String, kind: String, derived: bool| {
376                if from.trim().is_empty() || to.trim().is_empty() || from == to {
377                    return;
378                }
379                if !current_ids.contains(&from) || !current_ids.contains(&to) {
380                    return;
381                }
382                let key = format!("{from}|{kind}|{to}");
383                if !seen.insert(key) {
384                    return;
385                }
386                edges.push(serde_json::json!({
387                    "from": from,
388                    "to": to,
389                    "kind": kind,
390                    "derived": derived,
391                }));
392            };
393
394            // Explicit user-managed relations.
395            for e in &graph.edges {
396                push_edge(e.from.id(), e.to.id(), e.kind.as_str().to_string(), false);
397            }
398
399            // Derived: `supersedes` links (stored on facts).
400            for f in knowledge.facts.iter().filter(|f| f.is_current()) {
401                let Some(to) = f
402                    .supersedes
403                    .as_deref()
404                    .and_then(crate::core::knowledge_relations::parse_node_ref)
405                else {
406                    continue;
407                };
408                let from = format!("{}/{}", f.category, f.key);
409                push_edge(from, to.id(), "supersedes".to_string(), true);
410            }
411
412            // Derived: soft references in values like `category/key` or `category:key`.
413            for f in knowledge.facts.iter().filter(|f| f.is_current()) {
414                let from = format!("{}/{}", f.category, f.key);
415                for raw in f.value.split_whitespace() {
416                    let tok = raw.trim_matches(|c: char| {
417                        !c.is_ascii_alphanumeric() && c != '/' && c != ':' && c != '_' && c != '-'
418                    });
419                    let Some(to) = crate::core::knowledge_relations::parse_node_ref(tok) else {
420                        continue;
421                    };
422                    if to.id() == from {
423                        continue;
424                    }
425                    push_edge(from.clone(), to.id(), "related_to".to_string(), true);
426                }
427            }
428
429            let max_edges = policy.knowledge.max_facts.saturating_mul(8);
430            if max_edges > 0 && edges.len() > max_edges {
431                edges.truncate(max_edges);
432            }
433
434            let payload = serde_json::json!({
435                "project_root": project_root,
436                "project_hash": knowledge.project_hash,
437                "edges": edges,
438                "explicit_edges_total": graph.edges.len(),
439            });
440            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
441            ("200 OK", "application/json", json)
442        }
443        "/api/gotchas" => {
444            let project_root = detect_project_root_for_dashboard();
445            let store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
446            let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
447            ("200 OK", "application/json", json)
448        }
449        "/api/buddy" => {
450            let buddy = crate::core::buddy::BuddyState::compute();
451            let json = serde_json::to_string(&buddy).unwrap_or_else(|_| "{}".to_string());
452            ("200 OK", "application/json", json)
453        }
454        "/api/version" => {
455            let json = crate::core::version_check::version_info_json();
456            ("200 OK", "application/json", json)
457        }
458        "/api/pulse" => {
459            let stats_path = crate::core::data_dir::lean_ctx_data_dir()
460                .map(|d| d.join("stats.json"))
461                .unwrap_or_default();
462            let meta = std::fs::metadata(&stats_path).ok();
463            let size = meta.as_ref().map_or(0, std::fs::Metadata::len);
464            let mtime = meta
465                .and_then(|m| m.modified().ok())
466                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
467                .map_or(0, |d| d.as_secs());
468            use md5::Digest;
469            let hash = format!(
470                "{:x}",
471                md5::Md5::digest(format!("{size}-{mtime}").as_bytes())
472            );
473            let json = format!(r#"{{"hash":"{hash}","ts":{mtime}}}"#);
474            ("200 OK", "application/json", json)
475        }
476        "/api/heatmap" => {
477            let project_root = detect_project_root_for_dashboard();
478            let index = crate::core::graph_index::load_or_build(&project_root);
479            let entries = build_heatmap_json(&index);
480            ("200 OK", "application/json", entries)
481        }
482        "/metrics" => {
483            let prom = crate::core::telemetry::global_metrics().to_prometheus();
484            ("200 OK", "text/plain; version=0.0.4; charset=utf-8", prom)
485        }
486        "/api/anomaly" => {
487            let s = crate::core::anomaly::summary();
488            let json = serde_json::to_string(&s).unwrap_or_else(|_| "[]".to_string());
489            ("200 OK", "application/json", json)
490        }
491        "/api/episodes" => {
492            let root = detect_project_root_for_dashboard();
493            let hash = crate::core::project_hash::hash_project_root(&root);
494            let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
495            let stats = store.stats();
496            let recent: Vec<_> = store.recent(20).into_iter().cloned().collect();
497            let payload = serde_json::json!({
498                "project_root": root,
499                "project_hash": hash,
500                "stats": {
501                    "total_episodes": stats.total_episodes,
502                    "successes": stats.successes,
503                    "failures": stats.failures,
504                    "success_rate": stats.success_rate,
505                    "total_tokens": stats.total_tokens,
506                },
507                "recent": recent,
508            });
509            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
510            ("200 OK", "application/json", json)
511        }
512        "/api/procedures" => {
513            let root = detect_project_root_for_dashboard();
514            let hash = crate::core::project_hash::hash_project_root(&root);
515            let store = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
516            let task = extract_query_param(query_str, "task").or_else(|| {
517                crate::core::session::SessionState::load_latest_for_project_root(&root)
518                    .and_then(|s| s.task.map(|t| t.description))
519            });
520            let suggestions: Vec<serde_json::Value> = task.as_deref().map_or(Vec::new(), |t| {
521                store
522                    .suggest(t)
523                    .into_iter()
524                    .take(10)
525                    .map(|p| {
526                        serde_json::json!({
527                            "id": p.id,
528                            "name": p.name,
529                            "description": p.description,
530                            "confidence": p.confidence,
531                            "times_used": p.times_used,
532                            "times_succeeded": p.times_succeeded,
533                            "success_rate": p.success_rate(),
534                            "steps": p.steps,
535                            "activation_keywords": p.activation_keywords,
536                            "last_used": p.last_used,
537                            "created_at": p.created_at,
538                        })
539                    })
540                    .collect()
541            });
542            let payload = serde_json::json!({
543                "project_root": root,
544                "project_hash": hash,
545                "total_procedures": store.procedures.len(),
546                "task": task,
547                "suggestions": suggestions,
548                "procedures": store.procedures,
549            });
550            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
551            ("200 OK", "application/json", json)
552        }
553        "/api/verification" => {
554            let snap = crate::core::output_verification::stats_snapshot();
555            let json = serde_json::to_string(&snap).unwrap_or_else(|_| "{}".to_string());
556            ("200 OK", "application/json", json)
557        }
558        "/api/slos" => {
559            let snap = crate::core::slo::evaluate_quiet();
560            let history = crate::core::slo::violation_history(100);
561            let payload = serde_json::json!({
562                "snapshot": snap,
563                "history": history,
564            });
565            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
566            ("200 OK", "application/json", json)
567        }
568        "/api/events" => {
569            let evs = crate::core::events::load_events_from_file(200);
570            let json = serde_json::to_string(&evs).unwrap_or_else(|_| "[]".to_string());
571            ("200 OK", "application/json", json)
572        }
573        "/api/graph" => {
574            let root = detect_project_root_for_dashboard();
575            let index = crate::core::graph_index::load_or_build(&root);
576            let json = serde_json::to_string(&index).unwrap_or_else(|_| {
577                "{\"error\":\"failed to serialize project index\"}".to_string()
578            });
579            ("200 OK", "application/json", json)
580        }
581        "/api/graph/enrich" => {
582            let root = detect_project_root_for_dashboard();
583            let project_path = std::path::Path::new(&root);
584            let result = match crate::core::property_graph::CodeGraph::open(project_path) {
585                Ok(graph) => {
586                    match crate::core::graph_enricher::enrich_graph(&graph, project_path, 500) {
587                        Ok(stats) => {
588                            let nc = graph.node_count().unwrap_or(0);
589                            let ec = graph.edge_count().unwrap_or(0);
590                            serde_json::json!({
591                                "commits_indexed": stats.commits_indexed,
592                                "tests_indexed": stats.tests_indexed,
593                                "knowledge_indexed": stats.knowledge_indexed,
594                                "edges_created": stats.edges_created,
595                                "total_nodes": nc,
596                                "total_edges": ec,
597                            })
598                        }
599                        Err(e) => serde_json::json!({"error": e.to_string()}),
600                    }
601                }
602                Err(e) => serde_json::json!({"error": e.to_string()}),
603            };
604            let json = serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string());
605            ("200 OK", "application/json", json)
606        }
607        "/api/graph/stats" => {
608            let root = detect_project_root_for_dashboard();
609            let result = if let Some(open) = crate::core::graph_provider::open_best_effort(&root) {
610                let nc = open.provider.node_count().unwrap_or(0);
611                let ec = open.provider.edge_count().unwrap_or(0);
612                match open.source {
613                    crate::core::graph_provider::GraphProviderSource::PropertyGraph => {
614                        let project_path = std::path::Path::new(&root);
615                        let db_path = crate::core::property_graph::CodeGraph::open(project_path)
616                            .ok()
617                            .map(|g| g.db_path().display().to_string());
618                        serde_json::json!({
619                            "source": "property_graph",
620                            "node_count": nc,
621                            "edge_count": ec,
622                            "db_path": db_path,
623                        })
624                    }
625                    crate::core::graph_provider::GraphProviderSource::GraphIndex => {
626                        serde_json::json!({
627                            "source": "graph_index",
628                            "node_count": nc,
629                            "edge_count": ec,
630                        })
631                    }
632                }
633            } else {
634                serde_json::json!({
635                    "source": "none",
636                    "node_count": 0,
637                    "edge_count": 0,
638                })
639            };
640            let json = serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string());
641            ("200 OK", "application/json", json)
642        }
643        "/api/call-graph" => {
644            let root = detect_project_root_for_dashboard();
645            let index = crate::core::graph_index::load_or_build(&root);
646            let call_graph = crate::core::call_graph::CallGraph::load_or_build(&root, &index);
647            let _ = call_graph.save();
648            let payload = serde_json::json!({
649                "project_root": call_graph.project_root,
650                "edges": call_graph.edges,
651                "file_hashes": call_graph.file_hashes,
652                "indexed_file_count": index.files.len(),
653                "indexed_symbol_count": index.symbols.len(),
654                "analyzed_file_count": call_graph.file_hashes.len(),
655            });
656            let json = serde_json::to_string(&payload)
657                .unwrap_or_else(|_| "{\"error\":\"failed to serialize call graph\"}".to_string());
658            ("200 OK", "application/json", json)
659        }
660        "/api/feedback" => {
661            let store = crate::core::feedback::FeedbackStore::load();
662            let json = serde_json::to_string(&store).unwrap_or_else(|_| {
663                "{\"error\":\"failed to serialize feedback store\"}".to_string()
664            });
665            ("200 OK", "application/json", json)
666        }
667        "/api/symbols" => {
668            let root = detect_project_root_for_dashboard();
669            let index = crate::core::graph_index::load_or_build(&root);
670            let q = extract_query_param(query_str, "q");
671            let kind = extract_query_param(query_str, "kind");
672            let json = build_symbols_json(&index, q.as_deref(), kind.as_deref());
673            ("200 OK", "application/json", json)
674        }
675        "/api/routes" => {
676            let root = detect_project_root_for_dashboard();
677            let index = crate::core::graph_index::load_or_build(&root);
678            let routes =
679                crate::core::route_extractor::extract_routes_from_project(&root, &index.files);
680            let route_candidate_count = index
681                .files
682                .keys()
683                .filter(|p| {
684                    std::path::Path::new(p.as_str())
685                        .extension()
686                        .and_then(|e| e.to_str())
687                        .is_some_and(|e| {
688                            matches!(e, "js" | "ts" | "py" | "rs" | "java" | "rb" | "go" | "kt")
689                        })
690                })
691                .count();
692            let payload = serde_json::json!({
693                "routes": routes,
694                "indexed_file_count": index.files.len(),
695                "route_candidate_count": route_candidate_count,
696            });
697            let json =
698                serde_json::to_string(&payload).unwrap_or_else(|_| "{\"routes\":[]}".to_string());
699            ("200 OK", "application/json", json)
700        }
701        "/api/session" => {
702            let session = crate::core::session::SessionState::load_latest().unwrap_or_default();
703            let json = serde_json::to_string(&session)
704                .unwrap_or_else(|_| "{\"error\":\"failed to serialize session\"}".to_string());
705            ("200 OK", "application/json", json)
706        }
707        "/api/search-index" => {
708            let root_s = detect_project_root_for_dashboard();
709            let root = std::path::Path::new(&root_s);
710            let index = crate::core::bm25_index::BM25Index::load_or_build(root);
711            let summary = bm25_index_summary_json(&index);
712            let json = serde_json::to_string(&summary).unwrap_or_else(|_| {
713                "{\"error\":\"failed to serialize search index summary\"}".to_string()
714            });
715            ("200 OK", "application/json", json)
716        }
717        "/api/search" => {
718            let q = extract_query_param(query_str, "q").unwrap_or_default();
719            let limit: usize = extract_query_param(query_str, "limit")
720                .and_then(|l| l.parse().ok())
721                .unwrap_or(20);
722            if q.trim().is_empty() {
723                (
724                    "200 OK",
725                    "application/json",
726                    r#"{"results":[]}"#.to_string(),
727                )
728            } else {
729                let root_s = detect_project_root_for_dashboard();
730                let root = std::path::Path::new(&root_s);
731                let index = crate::core::bm25_index::BM25Index::load_or_build(root);
732                let hits = index.search(&q, limit);
733                let results: Vec<serde_json::Value> = hits
734                    .iter()
735                    .map(|r| {
736                        serde_json::json!({
737                            "score": (r.score * 100.0).round() / 100.0,
738                            "file_path": r.file_path,
739                            "symbol_name": r.symbol_name,
740                            "kind": r.kind,
741                            "start_line": r.start_line,
742                            "end_line": r.end_line,
743                            "snippet": r.snippet,
744                        })
745                    })
746                    .collect();
747                let json = serde_json::json!({ "results": results }).to_string();
748                ("200 OK", "application/json", json)
749            }
750        }
751        "/api/compression-demo" => {
752            let body = match extract_query_param(query_str, "path") {
753                None => r#"{"error":"missing path query parameter"}"#.to_string(),
754                Some(rel) => {
755                    let task = extract_query_param(query_str, "task");
756                    let root = detect_project_root_for_dashboard();
757                    let root_pb = std::path::Path::new(&root);
758                    let rel = normalize_dashboard_demo_path(&rel);
759                    let candidate = std::path::Path::new(&rel);
760
761                    let mut tried_paths: Vec<String> = Vec::new();
762                    let mut full: Option<std::path::PathBuf> = None;
763                    let mut content: Option<String> = None;
764
765                    let mut attempts: Vec<std::path::PathBuf> = Vec::new();
766                    if candidate.is_absolute() {
767                        attempts.push(candidate.to_path_buf());
768                    } else {
769                        attempts.push(root_pb.join(&rel));
770                        attempts.push(root_pb.join("rust").join(&rel));
771                    }
772
773                    for p in attempts {
774                        tried_paths.push(p.to_string_lossy().to_string());
775                        let p = if candidate.is_absolute() {
776                            p
777                        } else {
778                            match crate::core::pathjail::jail_path(&p, root_pb) {
779                                Ok(j) => j,
780                                Err(_) => continue,
781                            }
782                        };
783
784                        if let Ok(c) = std::fs::read_to_string(&p) {
785                            full = Some(p);
786                            content = Some(c);
787                            break;
788                        }
789                    }
790
791                    let mut resolved_from: Option<String> = None;
792                    let mut candidates: Vec<String> = Vec::new();
793
794                    if content.is_none() && !candidate.is_absolute() && !rel.trim().is_empty() {
795                        // Premium path healing: try to map stale paths to current indexed files.
796                        let index = crate::core::graph_index::load_or_build(&root);
797                        let requested_key = crate::core::graph_index::graph_match_key(&rel);
798                        let requested_name = requested_key.rsplit('/').next().unwrap_or("");
799
800                        let mut exact: Vec<String> = Vec::new();
801                        let mut suffix: Vec<String> = Vec::new();
802                        let mut filename: Vec<String> = Vec::new();
803                        let mut seen = std::collections::HashSet::<&str>::new();
804
805                        for p in index.files.keys() {
806                            let p_str = p.as_str();
807                            if !seen.insert(p_str) {
808                                continue;
809                            }
810                            let p_key = crate::core::graph_index::graph_match_key(p_str);
811                            if p_key == requested_key {
812                                exact.push(p_str.to_string());
813                            } else if !requested_key.is_empty() && p_key.ends_with(&requested_key) {
814                                suffix.push(p_str.to_string());
815                            } else if !requested_name.is_empty()
816                                && p_key
817                                    .rsplit('/')
818                                    .next()
819                                    .is_some_and(|n| n == requested_name)
820                            {
821                                filename.push(p_str.to_string());
822                            }
823                        }
824
825                        let mut best = if !exact.is_empty() {
826                            exact
827                        } else if !suffix.is_empty() {
828                            suffix
829                        } else {
830                            filename
831                        };
832                        best.sort_by_key(String::len);
833
834                        if best.len() == 1 {
835                            let rel2 = best[0].clone();
836                            let p2 = root_pb.join(rel2.trim_start_matches(['/', '\\']));
837                            tried_paths.push(p2.to_string_lossy().to_string());
838                            if let Ok(p2) = crate::core::pathjail::jail_path(&p2, root_pb) {
839                                if let Ok(c2) = std::fs::read_to_string(&p2) {
840                                    full = Some(p2);
841                                    content = Some(c2);
842                                    resolved_from = Some(rel2);
843                                } else {
844                                    candidates = best;
845                                }
846                            } else {
847                                candidates = best;
848                            }
849                        } else if best.len() > 1 {
850                            best.truncate(10);
851                            candidates = best;
852                        }
853                    }
854
855                    match (full, content) {
856                        (Some(full), Some(content)) => {
857                            let ext = full.extension().and_then(|e| e.to_str()).unwrap_or("rs");
858                            let path_str = full.to_string_lossy().to_string();
859                            let original_lines = content.lines().count();
860                            let original_tokens = crate::core::tokens::count_tokens(&content);
861                            let modes = compression_demo_modes_json(
862                                &content,
863                                &path_str,
864                                ext,
865                                original_tokens,
866                                task.as_deref(),
867                            );
868                            let original_preview: String = content.chars().take(8000).collect();
869                            serde_json::json!({
870                                "path": path_str,
871                                "task": task,
872                                "original_lines": original_lines,
873                                "original_tokens": original_tokens,
874                                "original": original_preview,
875                                "modes": modes,
876                                "resolved_from": resolved_from,
877                            })
878                            .to_string()
879                        }
880                        _ => serde_json::json!({
881                            "error": "failed to read file",
882                            "project_root": root,
883                            "requested_path": rel,
884                            "candidates": candidates,
885                            "tried_paths": tried_paths,
886                        })
887                        .to_string(),
888                    }
889                }
890            };
891            ("200 OK", "application/json", body)
892        }
893        "/" | "/index.html" => {
894            let mut html = DASHBOARD_HTML.to_string();
895            if let Some(t) = token {
896                let expected = t.as_str();
897                let valid_query = query_token
898                    .as_ref()
899                    .is_some_and(|q| constant_time_eq(q.as_bytes(), expected.as_bytes()));
900                if valid_query {
901                    let script = format!(
902                        "<script>window.__LEAN_CTX_TOKEN__=\"{expected}\";try{{if(location.search.includes('token=')){{history.replaceState(null,'',location.pathname);}}}}catch(e){{}}</script>"
903                    );
904                    html = html.replacen("<head>", &format!("<head>{script}"), 1);
905                }
906            }
907            ("200 OK", "text/html; charset=utf-8", html)
908        }
909        "/api/pipeline-stats" => {
910            let stats = crate::core::pipeline::PipelineStats::load();
911            let json = serde_json::to_string(&stats).unwrap_or_else(|_| "{}".to_string());
912            ("200 OK", "application/json", json)
913        }
914        "/api/context-ledger" => {
915            let ledger = crate::core::context_ledger::ContextLedger::load();
916            let pressure = ledger.pressure();
917            let payload = serde_json::json!({
918                "window_size": ledger.window_size,
919                "entries_count": ledger.entries.len(),
920                "total_tokens_sent": ledger.total_tokens_sent,
921                "total_tokens_saved": ledger.total_tokens_saved,
922                "compression_ratio": ledger.compression_ratio(),
923                "pressure": {
924                    "utilization": pressure.utilization,
925                    "remaining_tokens": pressure.remaining_tokens,
926                    "recommendation": format!("{:?}", pressure.recommendation),
927                },
928                "mode_distribution": ledger.mode_distribution(),
929                "entries": ledger.entries.iter().take(50).collect::<Vec<_>>(),
930            });
931            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
932            ("200 OK", "application/json", json)
933        }
934        "/api/context-control" => {
935            let project_root = detect_project_root_for_dashboard();
936            let mut ledger = crate::core::context_ledger::ContextLedger::load();
937            let mut overlays = crate::core::context_overlay::OverlayStore::load_project(
938                &std::path::PathBuf::from(&project_root),
939            );
940            let mut args = serde_json::Map::new();
941            args.insert(
942                "action".to_string(),
943                serde_json::Value::String("list".to_string()),
944            );
945            let result = crate::tools::ctx_control::handle(Some(&args), &mut ledger, &mut overlays);
946            ledger.save();
947            let _ = overlays.save_project(&std::path::PathBuf::from(&project_root));
948            let payload = serde_json::json!({ "result": result });
949            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
950            ("200 OK", "application/json", json)
951        }
952        "/api/context-field" => {
953            let ledger = crate::core::context_ledger::ContextLedger::load();
954            let field = crate::core::context_field::ContextField::new();
955            let budget = crate::core::context_field::TokenBudget {
956                total: ledger.window_size,
957                used: ledger.total_tokens_sent,
958            };
959            let items: Vec<serde_json::Value> = ledger
960                .entries
961                .iter()
962                .map(|e| {
963                    let phi = e.phi.unwrap_or_else(|| {
964                        field.compute_phi(&crate::core::context_field::FieldSignals {
965                            relevance: 0.3,
966                            ..Default::default()
967                        })
968                    });
969                    serde_json::json!({
970                        "path": e.path,
971                        "phi": phi,
972                        "state": e.state,
973                        "view": e.active_view,
974                        "tokens": e.sent_tokens,
975                        "kind": e.kind,
976                    })
977                })
978                .collect();
979            let payload = serde_json::json!({
980                "temperature": budget.temperature(),
981                "budget_total": budget.total,
982                "budget_used": budget.used,
983                "budget_remaining": budget.remaining(),
984                "items": items,
985            });
986            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
987            ("200 OK", "application/json", json)
988        }
989        "/api/context-handles" => {
990            let ledger = crate::core::context_ledger::ContextLedger::load();
991            let project_root = detect_project_root_for_dashboard();
992            let policies = crate::core::context_policies::PolicySet::load_project(
993                &std::path::PathBuf::from(&project_root),
994            );
995            let candidates = crate::tools::ctx_plan::plan_to_candidates(&ledger, &policies);
996            let mut registry = crate::core::context_handles::HandleRegistry::new();
997            for c in &candidates {
998                if c.state == crate::core::context_field::ContextState::Excluded {
999                    continue;
1000                }
1001                let summary = format!("{} {}", c.path, c.selected_view.as_str());
1002                registry.register(
1003                    c.id.clone(),
1004                    c.kind,
1005                    &c.path,
1006                    &summary,
1007                    &c.view_costs,
1008                    c.phi,
1009                    c.pinned,
1010                );
1011            }
1012            let json = serde_json::to_string(&registry).unwrap_or_else(|_| "{}".to_string());
1013            ("200 OK", "application/json", json)
1014        }
1015        "/api/context-overlay-history" => {
1016            let project_root = detect_project_root_for_dashboard();
1017            let store = crate::core::context_overlay::OverlayStore::load_project(
1018                &std::path::PathBuf::from(&project_root),
1019            );
1020            let json = serde_json::to_string(store.all()).unwrap_or_else(|_| "[]".to_string());
1021            ("200 OK", "application/json", json)
1022        }
1023        "/api/context-plan" => {
1024            let ledger = crate::core::context_ledger::ContextLedger::load();
1025            let project_root = detect_project_root_for_dashboard();
1026            let policies = crate::core::context_policies::PolicySet::load_project(
1027                &std::path::PathBuf::from(&project_root),
1028            );
1029            let text = crate::tools::ctx_plan::handle(None, &ledger, &policies);
1030            let payload = serde_json::json!({ "plan": text });
1031            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
1032            ("200 OK", "application/json", json)
1033        }
1034        "/api/intent" => {
1035            let session_path = crate::core::data_dir::lean_ctx_data_dir()
1036                .ok()
1037                .map(|d| d.join("sessions"));
1038            let mut intent_data = serde_json::json!({"active": false});
1039            if let Some(dir) = session_path {
1040                if let Ok(entries) = std::fs::read_dir(&dir) {
1041                    let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
1042                    for e in entries.flatten() {
1043                        if e.path().extension().is_some_and(|ext| ext == "json") {
1044                            if let Ok(meta) = e.metadata() {
1045                                let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
1046                                if newest.as_ref().is_none_or(|(t, _)| mtime > *t) {
1047                                    newest = Some((mtime, e.path()));
1048                                }
1049                            }
1050                        }
1051                    }
1052                    if let Some((_, path)) = newest {
1053                        if let Ok(content) = std::fs::read_to_string(&path) {
1054                            if let Ok(session) = serde_json::from_str::<serde_json::Value>(&content)
1055                            {
1056                                if let Some(intent) = session.get("active_structured_intent") {
1057                                    if !intent.is_null() {
1058                                        intent_data = serde_json::json!({
1059                                            "active": true,
1060                                            "intent": intent,
1061                                            "session_file": path.file_name().unwrap_or_default().to_string_lossy(),
1062                                        });
1063                                    }
1064                                }
1065                            }
1066                        }
1067                    }
1068                }
1069            }
1070            let json = serde_json::to_string(&intent_data).unwrap_or_else(|_| "{}".to_string());
1071            ("200 OK", "application/json", json)
1072        }
1073        "/favicon.ico" => ("204 No Content", "text/plain", String::new()),
1074        _ => ("404 Not Found", "text/plain", "Not Found".to_string()),
1075    }
1076}
1077
1078fn check_auth(request: &str, expected_token: &str) -> bool {
1079    for line in request.lines() {
1080        let lower = line.to_lowercase();
1081        if lower.starts_with("authorization:") {
1082            let value = line["authorization:".len()..].trim();
1083            if let Some(token) = value
1084                .strip_prefix("Bearer ")
1085                .or_else(|| value.strip_prefix("bearer "))
1086            {
1087                return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
1088            }
1089        }
1090    }
1091    false
1092}
1093
1094fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1095    if a.len() != b.len() {
1096        return false;
1097    }
1098    a.iter()
1099        .zip(b.iter())
1100        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
1101        == 0
1102}
1103
1104fn extract_query_param(qs: &str, key: &str) -> Option<String> {
1105    for pair in qs.split('&') {
1106        let Some((k, v)) = pair.split_once('=') else {
1107            continue;
1108        };
1109        if k == key {
1110            return Some(percent_decode_query_component(v));
1111        }
1112    }
1113    None
1114}
1115
1116fn percent_decode_query_component(s: &str) -> String {
1117    let mut out: Vec<u8> = Vec::with_capacity(s.len());
1118    let b = s.as_bytes();
1119    let mut i = 0;
1120    while i < b.len() {
1121        match b[i] {
1122            b'+' => {
1123                out.push(b' ');
1124                i += 1;
1125            }
1126            b'%' if i + 2 < b.len() => {
1127                let h1 = (b[i + 1] as char).to_digit(16);
1128                let h2 = (b[i + 2] as char).to_digit(16);
1129                if let (Some(a), Some(d)) = (h1, h2) {
1130                    out.push(((a << 4) | d) as u8);
1131                    i += 3;
1132                } else {
1133                    out.push(b'%');
1134                    i += 1;
1135                }
1136            }
1137            _ => {
1138                out.push(b[i]);
1139                i += 1;
1140            }
1141        }
1142    }
1143    String::from_utf8_lossy(&out).into_owned()
1144}
1145
1146fn normalize_dashboard_demo_path(path: &str) -> String {
1147    let trimmed = path.trim();
1148    if trimmed.is_empty() {
1149        return String::new();
1150    }
1151
1152    let candidate = Path::new(trimmed);
1153    if candidate.is_absolute() || is_windows_absolute_path(trimmed) {
1154        return trimmed.to_string();
1155    }
1156
1157    let mut p = trimmed;
1158    while p.starts_with("./") || p.starts_with(".\\") {
1159        p = &p[2..];
1160    }
1161
1162    p.trim_start_matches(['\\', '/'])
1163        .replace('\\', std::path::MAIN_SEPARATOR_STR)
1164}
1165
1166fn is_windows_absolute_path(path: &str) -> bool {
1167    let bytes = path.as_bytes();
1168    if bytes.len() >= 3
1169        && bytes[0].is_ascii_alphabetic()
1170        && bytes[1] == b':'
1171        && matches!(bytes[2], b'\\' | b'/')
1172    {
1173        return true;
1174    }
1175
1176    path.starts_with("\\\\") || path.starts_with("//")
1177}
1178
1179fn compression_mode_json(output: &str, original_tokens: usize) -> serde_json::Value {
1180    let tokens = crate::core::tokens::count_tokens(output);
1181    let savings_pct = if original_tokens > 0 {
1182        ((original_tokens.saturating_sub(tokens)) as f64 / original_tokens as f64 * 100.0).round()
1183            as i64
1184    } else {
1185        0
1186    };
1187    serde_json::json!({
1188        "output": output,
1189        "tokens": tokens,
1190        "savings_pct": savings_pct
1191    })
1192}
1193
1194fn compression_demo_modes_json(
1195    content: &str,
1196    path: &str,
1197    ext: &str,
1198    original_tokens: usize,
1199    task: Option<&str>,
1200) -> serde_json::Value {
1201    let map_out = crate::core::signatures::extract_file_map(path, content);
1202    let sig_out = crate::core::signatures::extract_signatures(content, ext)
1203        .iter()
1204        .map(super::core::signatures::Signature::to_compact)
1205        .collect::<Vec<_>>()
1206        .join("\n");
1207    let aggressive_out = crate::core::filters::aggressive_filter(content);
1208    let entropy_out = crate::core::entropy::entropy_compress_adaptive(content, path).output;
1209
1210    let mut cache = crate::core::cache::SessionCache::new();
1211    let reference_out =
1212        crate::tools::ctx_read::handle(&mut cache, path, "reference", crate::tools::CrpMode::Off);
1213    let task_out = task.filter(|t| !t.trim().is_empty()).map(|t| {
1214        crate::tools::ctx_read::handle_with_task(
1215            &mut cache,
1216            path,
1217            "task",
1218            crate::tools::CrpMode::Off,
1219            Some(t),
1220        )
1221    });
1222
1223    serde_json::json!({
1224        "map": compression_mode_json(&map_out, original_tokens),
1225        "signatures": compression_mode_json(&sig_out, original_tokens),
1226        "reference": compression_mode_json(&reference_out, original_tokens),
1227        "aggressive": compression_mode_json(&aggressive_out, original_tokens),
1228        "entropy": compression_mode_json(&entropy_out, original_tokens),
1229        "task": task_out.as_deref().map_or(serde_json::Value::Null, |s| compression_mode_json(s, original_tokens)),
1230    })
1231}
1232
1233fn bm25_index_summary_json(index: &crate::core::bm25_index::BM25Index) -> serde_json::Value {
1234    let mut sorted: Vec<&crate::core::bm25_index::CodeChunk> = index.chunks.iter().collect();
1235    sorted.sort_by_key(|c| std::cmp::Reverse(c.token_count));
1236    let top: Vec<serde_json::Value> = sorted
1237        .into_iter()
1238        .take(20)
1239        .map(|c| {
1240            serde_json::json!({
1241                "file_path": c.file_path,
1242                "symbol_name": c.symbol_name,
1243                "token_count": c.token_count,
1244                "kind": c.kind,
1245                "start_line": c.start_line,
1246                "end_line": c.end_line,
1247            })
1248        })
1249        .collect();
1250    let mut lang: HashMap<String, usize> = HashMap::new();
1251    for c in &index.chunks {
1252        let e = std::path::Path::new(&c.file_path)
1253            .extension()
1254            .and_then(|e| e.to_str())
1255            .unwrap_or("")
1256            .to_string();
1257        *lang.entry(e).or_default() += 1;
1258    }
1259    serde_json::json!({
1260        "doc_count": index.doc_count,
1261        "chunk_count": index.chunks.len(),
1262        "top_chunks_by_token_count": top,
1263        "language_distribution": lang,
1264    })
1265}
1266
1267fn build_symbols_json(
1268    index: &crate::core::graph_index::ProjectIndex,
1269    query: Option<&str>,
1270    kind: Option<&str>,
1271) -> String {
1272    let query = query
1273        .map(|q| q.trim().to_lowercase())
1274        .filter(|q| !q.is_empty());
1275    let kind = kind
1276        .map(|k| k.trim().to_lowercase())
1277        .filter(|k| !k.is_empty());
1278
1279    let mut symbols: Vec<&crate::core::graph_index::SymbolEntry> = index
1280        .symbols
1281        .values()
1282        .filter(|sym| {
1283            let kind_match = match kind.as_ref() {
1284                Some(k) => sym.kind.eq_ignore_ascii_case(k),
1285                None => true,
1286            };
1287            let query_match = match query.as_ref() {
1288                Some(q) => {
1289                    let name = sym.name.to_lowercase();
1290                    let file = sym.file.to_lowercase();
1291                    let symbol_kind = sym.kind.to_lowercase();
1292                    name.contains(q) || file.contains(q) || symbol_kind.contains(q)
1293                }
1294                None => true,
1295            };
1296            kind_match && query_match
1297        })
1298        .collect();
1299
1300    symbols.sort_by(|a, b| {
1301        a.file
1302            .cmp(&b.file)
1303            .then_with(|| a.start_line.cmp(&b.start_line))
1304            .then_with(|| a.name.cmp(&b.name))
1305    });
1306    symbols.truncate(500);
1307
1308    serde_json::to_string(
1309        &symbols
1310            .into_iter()
1311            .map(|sym| {
1312                serde_json::json!({
1313                    "name": sym.name,
1314                    "kind": sym.kind,
1315                    "file": sym.file,
1316                    "start_line": sym.start_line,
1317                    "end_line": sym.end_line,
1318                    "is_exported": sym.is_exported,
1319                })
1320            })
1321            .collect::<Vec<_>>(),
1322    )
1323    .unwrap_or_else(|_| "[]".to_string())
1324}
1325
1326fn build_heatmap_json(index: &crate::core::graph_index::ProjectIndex) -> String {
1327    let mut connection_counts: std::collections::HashMap<String, usize> =
1328        std::collections::HashMap::new();
1329    for edge in &index.edges {
1330        *connection_counts.entry(edge.from.clone()).or_default() += 1;
1331        *connection_counts.entry(edge.to.clone()).or_default() += 1;
1332    }
1333
1334    let max_tokens = index
1335        .files
1336        .values()
1337        .map(|f| f.token_count)
1338        .max()
1339        .unwrap_or(1) as f64;
1340    let max_connections = connection_counts.values().max().copied().unwrap_or(1) as f64;
1341
1342    let mut entries: Vec<serde_json::Value> = index
1343        .files
1344        .values()
1345        .map(|f| {
1346            let connections = connection_counts.get(&f.path).copied().unwrap_or(0);
1347            let token_norm = f.token_count as f64 / max_tokens;
1348            let conn_norm = connections as f64 / max_connections;
1349            let heat = token_norm * 0.4 + conn_norm * 0.6;
1350            serde_json::json!({
1351                "path": f.path,
1352                "tokens": f.token_count,
1353                "connections": connections,
1354                "language": f.language,
1355                "heat": (heat * 100.0).round() / 100.0,
1356            })
1357        })
1358        .collect();
1359
1360    entries.sort_by(|a, b| {
1361        b["heat"]
1362            .as_f64()
1363            .unwrap_or(0.0)
1364            .partial_cmp(&a["heat"].as_f64().unwrap_or(0.0))
1365            .unwrap_or(std::cmp::Ordering::Equal)
1366    });
1367
1368    serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
1369}
1370
1371fn build_agents_json() -> String {
1372    let mut registry = crate::core::agents::AgentRegistry::load_or_create();
1373    registry.cleanup_stale(24);
1374    let _ = registry.save();
1375
1376    let agents: Vec<serde_json::Value> = registry
1377        .agents
1378        .iter()
1379        .filter(|a| {
1380            a.status != crate::core::agents::AgentStatus::Finished
1381                && crate::core::agents::is_process_alive(a.pid)
1382        })
1383        .map(|a| {
1384            let age_min = (chrono::Utc::now() - a.last_active).num_minutes();
1385            serde_json::json!({
1386                "id": a.agent_id,
1387                "type": a.agent_type,
1388                "role": a.role,
1389                "status": format!("{}", a.status),
1390                "status_message": a.status_message,
1391                "last_active_minutes_ago": age_min,
1392                "pid": a.pid
1393            })
1394        })
1395        .collect();
1396
1397    let pending_msgs = registry.scratchpad.len();
1398
1399    let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
1400        .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".lean-ctx"))
1401        .join("agents")
1402        .join("shared");
1403    let shared_count = if shared_dir.exists() {
1404        std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
1405    } else {
1406        0
1407    };
1408
1409    serde_json::json!({
1410        "agents": agents,
1411        "total_active": agents.len(),
1412        "pending_messages": pending_msgs,
1413        "shared_contexts": shared_count
1414    })
1415    .to_string()
1416}
1417
1418fn detect_project_root_for_dashboard() -> String {
1419    if let Ok(explicit) = std::env::var("LEAN_CTX_DASHBOARD_PROJECT") {
1420        if !explicit.trim().is_empty() {
1421            return promote_to_git_root(&explicit);
1422        }
1423    }
1424
1425    if let Some(session) = crate::core::session::SessionState::load_latest() {
1426        // Try project_root first, but only if it resolves to a real project (has .git or markers).
1427        // MCP sessions often set project_root to a temp sandbox directory that contains no code.
1428        if let Some(root) = session.project_root.as_deref() {
1429            if !root.trim().is_empty() {
1430                if let Some(git_root) = git_root_for(root) {
1431                    return git_root;
1432                }
1433                if is_real_project(root) {
1434                    return root.to_string();
1435                }
1436            }
1437        }
1438        if let Some(cwd) = session.shell_cwd.as_deref() {
1439            if !cwd.trim().is_empty() {
1440                let r = crate::core::protocol::detect_project_root_or_cwd(cwd);
1441                return promote_to_git_root(&r);
1442            }
1443        }
1444        if let Some(last) = session.files_touched.last() {
1445            if !last.path.trim().is_empty() {
1446                if let Some(parent) = Path::new(&last.path).parent() {
1447                    let p = parent.to_string_lossy().to_string();
1448                    let r = crate::core::protocol::detect_project_root_or_cwd(&p);
1449                    return promote_to_git_root(&r);
1450                }
1451            }
1452        }
1453    }
1454
1455    let cwd = std::env::current_dir()
1456        .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
1457    let r = crate::core::protocol::detect_project_root_or_cwd(&cwd);
1458    promote_to_git_root(&r)
1459}
1460
1461fn is_real_project(path: &str) -> bool {
1462    let p = Path::new(path);
1463    if !p.is_dir() {
1464        return false;
1465    }
1466    const MARKERS: &[&str] = &[
1467        ".git",
1468        "Cargo.toml",
1469        "package.json",
1470        "go.mod",
1471        "pyproject.toml",
1472        "requirements.txt",
1473        "pom.xml",
1474        "build.gradle",
1475        "CMakeLists.txt",
1476        ".lean-ctx.toml",
1477    ];
1478    MARKERS.iter().any(|m| p.join(m).exists())
1479}
1480
1481fn promote_to_git_root(path: &str) -> String {
1482    git_root_for(path).unwrap_or_else(|| path.to_string())
1483}
1484
1485fn git_root_for(path: &str) -> Option<String> {
1486    let mut p = Path::new(path);
1487    loop {
1488        let git = p.join(".git");
1489        if git.exists() {
1490            return Some(p.to_string_lossy().to_string());
1491        }
1492        p = p.parent()?;
1493    }
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498    use super::*;
1499    use tempfile::tempdir;
1500
1501    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1502
1503    #[test]
1504    fn check_auth_with_valid_bearer() {
1505        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
1506        assert!(check_auth(req, "lctx_abc123"));
1507    }
1508
1509    #[test]
1510    fn check_auth_with_invalid_bearer() {
1511        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
1512        assert!(!check_auth(req, "lctx_abc123"));
1513    }
1514
1515    #[test]
1516    fn check_auth_missing_header() {
1517        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
1518        assert!(!check_auth(req, "lctx_abc123"));
1519    }
1520
1521    #[test]
1522    fn check_auth_lowercase_bearer() {
1523        let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
1524        assert!(check_auth(req, "lctx_abc123"));
1525    }
1526
1527    #[test]
1528    fn query_token_parsing() {
1529        let raw_path = "/index.html?token=lctx_abc123&other=val";
1530        let idx = raw_path.find('?').unwrap();
1531        let qs = &raw_path[idx + 1..];
1532        let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
1533        assert_eq!(tok, Some("lctx_abc123"));
1534    }
1535
1536    #[test]
1537    fn api_path_detection() {
1538        assert!("/api/stats".starts_with("/api/"));
1539        assert!("/api/version".starts_with("/api/"));
1540        assert!(!"/".starts_with("/api/"));
1541        assert!(!"/index.html".starts_with("/api/"));
1542        assert!(!"/favicon.ico".starts_with("/api/"));
1543    }
1544
1545    #[test]
1546    fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
1547        let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
1548        assert_eq!(
1549            normalized,
1550            format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
1551        );
1552    }
1553
1554    #[test]
1555    fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
1556        let input = r"C:\repo\backend\list_tables.js";
1557        assert_eq!(normalize_dashboard_demo_path(input), input);
1558    }
1559
1560    #[test]
1561    fn normalize_dashboard_demo_path_preserves_unc_path() {
1562        let input = r"\\server\share\backend\list_tables.js";
1563        assert_eq!(normalize_dashboard_demo_path(input), input);
1564    }
1565
1566    #[test]
1567    fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
1568        assert_eq!(
1569            normalize_dashboard_demo_path("./src/main.rs"),
1570            "src/main.rs"
1571        );
1572        assert_eq!(
1573            normalize_dashboard_demo_path(r".\src\main.rs"),
1574            format!("src{}main.rs", std::path::MAIN_SEPARATOR)
1575        );
1576    }
1577
1578    #[test]
1579    fn api_profile_returns_json() {
1580        let (_status, _ct, body) = route_response("/api/profile", "", None, None);
1581        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1582        assert!(v.get("active_name").is_some(), "missing active_name");
1583        assert!(
1584            v.pointer("/profile/profile/name")
1585                .and_then(|n| n.as_str())
1586                .is_some(),
1587            "missing profile.profile.name"
1588        );
1589        assert!(v.get("available").and_then(|a| a.as_array()).is_some());
1590    }
1591
1592    #[test]
1593    fn api_episodes_returns_json() {
1594        let (_status, _ct, body) = route_response("/api/episodes", "", None, None);
1595        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1596        assert!(v.get("project_hash").is_some());
1597        assert!(v.get("stats").is_some());
1598        assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
1599    }
1600
1601    #[test]
1602    fn api_procedures_returns_json() {
1603        let (_status, _ct, body) = route_response("/api/procedures", "", None, None);
1604        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1605        assert!(v.get("project_hash").is_some());
1606        assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
1607        assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
1608    }
1609
1610    #[test]
1611    fn api_compression_demo_heals_moved_file_paths() {
1612        let _g = ENV_LOCK.lock().expect("env lock");
1613        let td = tempdir().expect("tempdir");
1614        let root = td.path();
1615        std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
1616        std::fs::write(
1617            root.join("src").join("moved").join("foo.rs"),
1618            "pub fn foo() { println!(\"hi\"); }\n",
1619        )
1620        .expect("write foo.rs");
1621
1622        let root_s = root.to_string_lossy().to_string();
1623        std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
1624
1625        let (_status, _ct, body) =
1626            route_response("/api/compression-demo", "path=src/foo.rs", None, None);
1627        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1628        assert!(v.get("error").is_none(), "unexpected error: {body}");
1629        assert_eq!(
1630            v.get("resolved_from").and_then(|x| x.as_str()),
1631            Some("src/moved/foo.rs")
1632        );
1633
1634        std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
1635        if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
1636            let _ = std::fs::remove_dir_all(dir);
1637        }
1638    }
1639}