Skip to main content

lean_ctx/dashboard/
mod.rs

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