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