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/gotchas" => {
340            let project_root = detect_project_root_for_dashboard();
341            let store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
342            let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
343            ("200 OK", "application/json", json)
344        }
345        "/api/buddy" => {
346            let buddy = crate::core::buddy::BuddyState::compute();
347            let json = serde_json::to_string(&buddy).unwrap_or_else(|_| "{}".to_string());
348            ("200 OK", "application/json", json)
349        }
350        "/api/version" => {
351            let json = crate::core::version_check::version_info_json();
352            ("200 OK", "application/json", json)
353        }
354        "/api/pulse" => {
355            let stats_path = crate::core::data_dir::lean_ctx_data_dir()
356                .map(|d| d.join("stats.json"))
357                .unwrap_or_default();
358            let meta = std::fs::metadata(&stats_path).ok();
359            let size = meta.as_ref().map_or(0, std::fs::Metadata::len);
360            let mtime = meta
361                .and_then(|m| m.modified().ok())
362                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
363                .map_or(0, |d| d.as_secs());
364            use md5::Digest;
365            let hash = format!(
366                "{:x}",
367                md5::Md5::digest(format!("{size}-{mtime}").as_bytes())
368            );
369            let json = format!(r#"{{"hash":"{hash}","ts":{mtime}}}"#);
370            ("200 OK", "application/json", json)
371        }
372        "/api/heatmap" => {
373            let project_root = detect_project_root_for_dashboard();
374            let index = crate::core::graph_index::load_or_build(&project_root);
375            let entries = build_heatmap_json(&index);
376            ("200 OK", "application/json", entries)
377        }
378        "/metrics" => {
379            let prom = crate::core::telemetry::global_metrics().to_prometheus();
380            ("200 OK", "text/plain; version=0.0.4; charset=utf-8", prom)
381        }
382        "/api/anomaly" => {
383            let s = crate::core::anomaly::summary();
384            let json = serde_json::to_string(&s).unwrap_or_else(|_| "[]".to_string());
385            ("200 OK", "application/json", json)
386        }
387        "/api/episodes" => {
388            let root = detect_project_root_for_dashboard();
389            let hash = crate::core::project_hash::hash_project_root(&root);
390            let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
391            let stats = store.stats();
392            let recent: Vec<_> = store.recent(20).into_iter().cloned().collect();
393            let payload = serde_json::json!({
394                "project_root": root,
395                "project_hash": hash,
396                "stats": {
397                    "total_episodes": stats.total_episodes,
398                    "successes": stats.successes,
399                    "failures": stats.failures,
400                    "success_rate": stats.success_rate,
401                    "total_tokens": stats.total_tokens,
402                },
403                "recent": recent,
404            });
405            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
406            ("200 OK", "application/json", json)
407        }
408        "/api/procedures" => {
409            let root = detect_project_root_for_dashboard();
410            let hash = crate::core::project_hash::hash_project_root(&root);
411            let store = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
412            let task = extract_query_param(query_str, "task").or_else(|| {
413                crate::core::session::SessionState::load_latest_for_project_root(&root)
414                    .and_then(|s| s.task.map(|t| t.description))
415            });
416            let suggestions: Vec<serde_json::Value> = task.as_deref().map_or(Vec::new(), |t| {
417                store
418                    .suggest(t)
419                    .into_iter()
420                    .take(10)
421                    .map(|p| {
422                        serde_json::json!({
423                            "id": p.id,
424                            "name": p.name,
425                            "description": p.description,
426                            "confidence": p.confidence,
427                            "times_used": p.times_used,
428                            "times_succeeded": p.times_succeeded,
429                            "success_rate": p.success_rate(),
430                            "steps": p.steps,
431                            "activation_keywords": p.activation_keywords,
432                            "last_used": p.last_used,
433                            "created_at": p.created_at,
434                        })
435                    })
436                    .collect()
437            });
438            let payload = serde_json::json!({
439                "project_root": root,
440                "project_hash": hash,
441                "total_procedures": store.procedures.len(),
442                "task": task,
443                "suggestions": suggestions,
444                "procedures": store.procedures,
445            });
446            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
447            ("200 OK", "application/json", json)
448        }
449        "/api/verification" => {
450            let snap = crate::core::output_verification::stats_snapshot();
451            let json = serde_json::to_string(&snap).unwrap_or_else(|_| "{}".to_string());
452            ("200 OK", "application/json", json)
453        }
454        "/api/slos" => {
455            let snap = crate::core::slo::evaluate_quiet();
456            let history = crate::core::slo::violation_history(100);
457            let payload = serde_json::json!({
458                "snapshot": snap,
459                "history": history,
460            });
461            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
462            ("200 OK", "application/json", json)
463        }
464        "/api/events" => {
465            let evs = crate::core::events::load_events_from_file(200);
466            let json = serde_json::to_string(&evs).unwrap_or_else(|_| "[]".to_string());
467            ("200 OK", "application/json", json)
468        }
469        "/api/graph" => {
470            let root = detect_project_root_for_dashboard();
471            let index = crate::core::graph_index::load_or_build(&root);
472            let json = serde_json::to_string(&index).unwrap_or_else(|_| {
473                "{\"error\":\"failed to serialize project index\"}".to_string()
474            });
475            ("200 OK", "application/json", json)
476        }
477        "/api/graph/enrich" => {
478            let root = detect_project_root_for_dashboard();
479            let project_path = std::path::Path::new(&root);
480            let result = match crate::core::property_graph::CodeGraph::open(project_path) {
481                Ok(graph) => {
482                    match crate::core::graph_enricher::enrich_graph(&graph, project_path, 500) {
483                        Ok(stats) => {
484                            let nc = graph.node_count().unwrap_or(0);
485                            let ec = graph.edge_count().unwrap_or(0);
486                            serde_json::json!({
487                                "commits_indexed": stats.commits_indexed,
488                                "tests_indexed": stats.tests_indexed,
489                                "knowledge_indexed": stats.knowledge_indexed,
490                                "edges_created": stats.edges_created,
491                                "total_nodes": nc,
492                                "total_edges": ec,
493                            })
494                        }
495                        Err(e) => serde_json::json!({"error": e.to_string()}),
496                    }
497                }
498                Err(e) => serde_json::json!({"error": e.to_string()}),
499            };
500            let json = serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string());
501            ("200 OK", "application/json", json)
502        }
503        "/api/graph/stats" => {
504            let root = detect_project_root_for_dashboard();
505            let project_path = std::path::Path::new(&root);
506            let result = match crate::core::property_graph::CodeGraph::open(project_path) {
507                Ok(graph) => {
508                    let nc = graph.node_count().unwrap_or(0);
509                    let ec = graph.edge_count().unwrap_or(0);
510                    serde_json::json!({
511                        "node_count": nc,
512                        "edge_count": ec,
513                        "db_path": graph.db_path().display().to_string(),
514                    })
515                }
516                Err(e) => serde_json::json!({"error": e.to_string()}),
517            };
518            let json = serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string());
519            ("200 OK", "application/json", json)
520        }
521        "/api/call-graph" => {
522            let root = detect_project_root_for_dashboard();
523            let index = crate::core::graph_index::load_or_build(&root);
524            let call_graph = crate::core::call_graph::CallGraph::load_or_build(&root, &index);
525            let _ = call_graph.save();
526            let payload = serde_json::json!({
527                "project_root": call_graph.project_root,
528                "edges": call_graph.edges,
529                "file_hashes": call_graph.file_hashes,
530                "indexed_file_count": index.files.len(),
531                "indexed_symbol_count": index.symbols.len(),
532                "analyzed_file_count": call_graph.file_hashes.len(),
533            });
534            let json = serde_json::to_string(&payload)
535                .unwrap_or_else(|_| "{\"error\":\"failed to serialize call graph\"}".to_string());
536            ("200 OK", "application/json", json)
537        }
538        "/api/feedback" => {
539            let store = crate::core::feedback::FeedbackStore::load();
540            let json = serde_json::to_string(&store).unwrap_or_else(|_| {
541                "{\"error\":\"failed to serialize feedback store\"}".to_string()
542            });
543            ("200 OK", "application/json", json)
544        }
545        "/api/symbols" => {
546            let root = detect_project_root_for_dashboard();
547            let index = crate::core::graph_index::load_or_build(&root);
548            let q = extract_query_param(query_str, "q");
549            let kind = extract_query_param(query_str, "kind");
550            let json = build_symbols_json(&index, q.as_deref(), kind.as_deref());
551            ("200 OK", "application/json", json)
552        }
553        "/api/routes" => {
554            let root = detect_project_root_for_dashboard();
555            let index = crate::core::graph_index::load_or_build(&root);
556            let routes =
557                crate::core::route_extractor::extract_routes_from_project(&root, &index.files);
558            let route_candidate_count = index
559                .files
560                .keys()
561                .filter(|p| {
562                    std::path::Path::new(p.as_str())
563                        .extension()
564                        .and_then(|e| e.to_str())
565                        .is_some_and(|e| {
566                            matches!(e, "js" | "ts" | "py" | "rs" | "java" | "rb" | "go" | "kt")
567                        })
568                })
569                .count();
570            let payload = serde_json::json!({
571                "routes": routes,
572                "indexed_file_count": index.files.len(),
573                "route_candidate_count": route_candidate_count,
574            });
575            let json =
576                serde_json::to_string(&payload).unwrap_or_else(|_| "{\"routes\":[]}".to_string());
577            ("200 OK", "application/json", json)
578        }
579        "/api/session" => {
580            let session = crate::core::session::SessionState::load_latest().unwrap_or_default();
581            let json = serde_json::to_string(&session)
582                .unwrap_or_else(|_| "{\"error\":\"failed to serialize session\"}".to_string());
583            ("200 OK", "application/json", json)
584        }
585        "/api/search-index" => {
586            let root_s = detect_project_root_for_dashboard();
587            let root = std::path::Path::new(&root_s);
588            let index = crate::core::vector_index::BM25Index::load_or_build(root);
589            let summary = bm25_index_summary_json(&index);
590            let json = serde_json::to_string(&summary).unwrap_or_else(|_| {
591                "{\"error\":\"failed to serialize search index summary\"}".to_string()
592            });
593            ("200 OK", "application/json", json)
594        }
595        "/api/search" => {
596            let q = extract_query_param(query_str, "q").unwrap_or_default();
597            let limit: usize = extract_query_param(query_str, "limit")
598                .and_then(|l| l.parse().ok())
599                .unwrap_or(20);
600            if q.trim().is_empty() {
601                (
602                    "200 OK",
603                    "application/json",
604                    r#"{"results":[]}"#.to_string(),
605                )
606            } else {
607                let root_s = detect_project_root_for_dashboard();
608                let root = std::path::Path::new(&root_s);
609                let index = crate::core::vector_index::BM25Index::load_or_build(root);
610                let hits = index.search(&q, limit);
611                let results: Vec<serde_json::Value> = hits
612                    .iter()
613                    .map(|r| {
614                        serde_json::json!({
615                            "score": (r.score * 100.0).round() / 100.0,
616                            "file_path": r.file_path,
617                            "symbol_name": r.symbol_name,
618                            "kind": r.kind,
619                            "start_line": r.start_line,
620                            "end_line": r.end_line,
621                            "snippet": r.snippet,
622                        })
623                    })
624                    .collect();
625                let json = serde_json::json!({ "results": results }).to_string();
626                ("200 OK", "application/json", json)
627            }
628        }
629        "/api/compression-demo" => {
630            let body = match extract_query_param(query_str, "path") {
631                None => r#"{"error":"missing path query parameter"}"#.to_string(),
632                Some(rel) => {
633                    let task = extract_query_param(query_str, "task");
634                    let root = detect_project_root_for_dashboard();
635                    let root_pb = std::path::Path::new(&root);
636                    let rel = normalize_dashboard_demo_path(&rel);
637                    let candidate = std::path::Path::new(&rel);
638
639                    let mut tried_paths: Vec<String> = Vec::new();
640                    let mut full: Option<std::path::PathBuf> = None;
641                    let mut content: Option<String> = None;
642
643                    let mut attempts: Vec<std::path::PathBuf> = Vec::new();
644                    if candidate.is_absolute() {
645                        attempts.push(candidate.to_path_buf());
646                    } else {
647                        attempts.push(root_pb.join(&rel));
648                        attempts.push(root_pb.join("rust").join(&rel));
649                    }
650
651                    for p in attempts {
652                        tried_paths.push(p.to_string_lossy().to_string());
653                        let p = if candidate.is_absolute() {
654                            p
655                        } else {
656                            match crate::core::pathjail::jail_path(&p, root_pb) {
657                                Ok(j) => j,
658                                Err(_) => continue,
659                            }
660                        };
661
662                        if let Ok(c) = std::fs::read_to_string(&p) {
663                            full = Some(p);
664                            content = Some(c);
665                            break;
666                        }
667                    }
668
669                    let mut resolved_from: Option<String> = None;
670                    let mut candidates: Vec<String> = Vec::new();
671
672                    if content.is_none() && !candidate.is_absolute() && !rel.trim().is_empty() {
673                        // Premium path healing: try to map stale paths to current indexed files.
674                        let index = crate::core::graph_index::load_or_build(&root);
675                        let requested_key = crate::core::graph_index::graph_match_key(&rel);
676                        let requested_name = requested_key.rsplit('/').next().unwrap_or("");
677
678                        let mut exact: Vec<String> = Vec::new();
679                        let mut suffix: Vec<String> = Vec::new();
680                        let mut filename: Vec<String> = Vec::new();
681                        let mut seen = std::collections::HashSet::<&str>::new();
682
683                        for p in index.files.keys() {
684                            let p_str = p.as_str();
685                            if !seen.insert(p_str) {
686                                continue;
687                            }
688                            let p_key = crate::core::graph_index::graph_match_key(p_str);
689                            if p_key == requested_key {
690                                exact.push(p_str.to_string());
691                            } else if !requested_key.is_empty() && p_key.ends_with(&requested_key) {
692                                suffix.push(p_str.to_string());
693                            } else if !requested_name.is_empty()
694                                && p_key
695                                    .rsplit('/')
696                                    .next()
697                                    .is_some_and(|n| n == requested_name)
698                            {
699                                filename.push(p_str.to_string());
700                            }
701                        }
702
703                        let mut best = if !exact.is_empty() {
704                            exact
705                        } else if !suffix.is_empty() {
706                            suffix
707                        } else {
708                            filename
709                        };
710                        best.sort_by_key(String::len);
711
712                        if best.len() == 1 {
713                            let rel2 = best[0].clone();
714                            let p2 = root_pb.join(rel2.trim_start_matches(['/', '\\']));
715                            tried_paths.push(p2.to_string_lossy().to_string());
716                            if let Ok(p2) = crate::core::pathjail::jail_path(&p2, root_pb) {
717                                if let Ok(c2) = std::fs::read_to_string(&p2) {
718                                    full = Some(p2);
719                                    content = Some(c2);
720                                    resolved_from = Some(rel2);
721                                } else {
722                                    candidates = best;
723                                }
724                            } else {
725                                candidates = best;
726                            }
727                        } else if best.len() > 1 {
728                            best.truncate(10);
729                            candidates = best;
730                        }
731                    }
732
733                    match (full, content) {
734                        (Some(full), Some(content)) => {
735                            let ext = full.extension().and_then(|e| e.to_str()).unwrap_or("rs");
736                            let path_str = full.to_string_lossy().to_string();
737                            let original_lines = content.lines().count();
738                            let original_tokens = crate::core::tokens::count_tokens(&content);
739                            let modes = compression_demo_modes_json(
740                                &content,
741                                &path_str,
742                                ext,
743                                original_tokens,
744                                task.as_deref(),
745                            );
746                            let original_preview: String = content.chars().take(8000).collect();
747                            serde_json::json!({
748                                "path": path_str,
749                                "task": task,
750                                "original_lines": original_lines,
751                                "original_tokens": original_tokens,
752                                "original": original_preview,
753                                "modes": modes,
754                                "resolved_from": resolved_from,
755                            })
756                            .to_string()
757                        }
758                        _ => serde_json::json!({
759                            "error": "failed to read file",
760                            "project_root": root,
761                            "requested_path": rel,
762                            "candidates": candidates,
763                            "tried_paths": tried_paths,
764                        })
765                        .to_string(),
766                    }
767                }
768            };
769            ("200 OK", "application/json", body)
770        }
771        "/" | "/index.html" => {
772            let mut html = DASHBOARD_HTML.to_string();
773            if let Some(tok) = query_token {
774                let script = format!(
775                    "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
776                    tok.replace('"', "")
777                );
778                html = html.replacen("<head>", &format!("<head>{script}"), 1);
779            } else if let Some(t) = token {
780                let script = format!(
781                    "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
782                    t.as_str()
783                );
784                html = html.replacen("<head>", &format!("<head>{script}"), 1);
785            }
786            ("200 OK", "text/html; charset=utf-8", html)
787        }
788        "/api/pipeline-stats" => {
789            let stats = crate::core::pipeline::PipelineStats::load();
790            let json = serde_json::to_string(&stats).unwrap_or_else(|_| "{}".to_string());
791            ("200 OK", "application/json", json)
792        }
793        "/api/context-ledger" => {
794            let ledger = crate::core::context_ledger::ContextLedger::load();
795            let pressure = ledger.pressure();
796            let payload = serde_json::json!({
797                "window_size": ledger.window_size,
798                "entries_count": ledger.entries.len(),
799                "total_tokens_sent": ledger.total_tokens_sent,
800                "total_tokens_saved": ledger.total_tokens_saved,
801                "compression_ratio": ledger.compression_ratio(),
802                "pressure": {
803                    "utilization": pressure.utilization,
804                    "remaining_tokens": pressure.remaining_tokens,
805                    "recommendation": format!("{:?}", pressure.recommendation),
806                },
807                "mode_distribution": ledger.mode_distribution(),
808                "entries": ledger.entries.iter().take(50).collect::<Vec<_>>(),
809            });
810            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
811            ("200 OK", "application/json", json)
812        }
813        "/api/intent" => {
814            let session_path = crate::core::data_dir::lean_ctx_data_dir()
815                .ok()
816                .map(|d| d.join("sessions"));
817            let mut intent_data = serde_json::json!({"active": false});
818            if let Some(dir) = session_path {
819                if let Ok(entries) = std::fs::read_dir(&dir) {
820                    let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
821                    for e in entries.flatten() {
822                        if e.path().extension().is_some_and(|ext| ext == "json") {
823                            if let Ok(meta) = e.metadata() {
824                                let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
825                                if newest.as_ref().is_none_or(|(t, _)| mtime > *t) {
826                                    newest = Some((mtime, e.path()));
827                                }
828                            }
829                        }
830                    }
831                    if let Some((_, path)) = newest {
832                        if let Ok(content) = std::fs::read_to_string(&path) {
833                            if let Ok(session) = serde_json::from_str::<serde_json::Value>(&content)
834                            {
835                                if let Some(intent) = session.get("active_structured_intent") {
836                                    if !intent.is_null() {
837                                        intent_data = serde_json::json!({
838                                            "active": true,
839                                            "intent": intent,
840                                            "session_file": path.file_name().unwrap_or_default().to_string_lossy(),
841                                        });
842                                    }
843                                }
844                            }
845                        }
846                    }
847                }
848            }
849            let json = serde_json::to_string(&intent_data).unwrap_or_else(|_| "{}".to_string());
850            ("200 OK", "application/json", json)
851        }
852        "/favicon.ico" => ("204 No Content", "text/plain", String::new()),
853        _ => ("404 Not Found", "text/plain", "Not Found".to_string()),
854    }
855}
856
857fn check_auth(request: &str, expected_token: &str) -> bool {
858    for line in request.lines() {
859        let lower = line.to_lowercase();
860        if lower.starts_with("authorization:") {
861            let value = line["authorization:".len()..].trim();
862            if let Some(token) = value.strip_prefix("Bearer ") {
863                return token.trim() == expected_token;
864            }
865            if let Some(token) = value.strip_prefix("bearer ") {
866                return token.trim() == expected_token;
867            }
868        }
869    }
870    false
871}
872
873fn extract_query_param(qs: &str, key: &str) -> Option<String> {
874    for pair in qs.split('&') {
875        let Some((k, v)) = pair.split_once('=') else {
876            continue;
877        };
878        if k == key {
879            return Some(percent_decode_query_component(v));
880        }
881    }
882    None
883}
884
885fn percent_decode_query_component(s: &str) -> String {
886    let mut out: Vec<u8> = Vec::with_capacity(s.len());
887    let b = s.as_bytes();
888    let mut i = 0;
889    while i < b.len() {
890        match b[i] {
891            b'+' => {
892                out.push(b' ');
893                i += 1;
894            }
895            b'%' if i + 2 < b.len() => {
896                let h1 = (b[i + 1] as char).to_digit(16);
897                let h2 = (b[i + 2] as char).to_digit(16);
898                if let (Some(a), Some(d)) = (h1, h2) {
899                    out.push(((a << 4) | d) as u8);
900                    i += 3;
901                } else {
902                    out.push(b'%');
903                    i += 1;
904                }
905            }
906            _ => {
907                out.push(b[i]);
908                i += 1;
909            }
910        }
911    }
912    String::from_utf8_lossy(&out).into_owned()
913}
914
915fn normalize_dashboard_demo_path(path: &str) -> String {
916    let trimmed = path.trim();
917    if trimmed.is_empty() {
918        return String::new();
919    }
920
921    let candidate = Path::new(trimmed);
922    if candidate.is_absolute() || is_windows_absolute_path(trimmed) {
923        return trimmed.to_string();
924    }
925
926    let mut p = trimmed;
927    while p.starts_with("./") || p.starts_with(".\\") {
928        p = &p[2..];
929    }
930
931    p.trim_start_matches(['\\', '/'])
932        .replace('\\', std::path::MAIN_SEPARATOR_STR)
933}
934
935fn is_windows_absolute_path(path: &str) -> bool {
936    let bytes = path.as_bytes();
937    if bytes.len() >= 3
938        && bytes[0].is_ascii_alphabetic()
939        && bytes[1] == b':'
940        && matches!(bytes[2], b'\\' | b'/')
941    {
942        return true;
943    }
944
945    path.starts_with("\\\\") || path.starts_with("//")
946}
947
948fn compression_mode_json(output: &str, original_tokens: usize) -> serde_json::Value {
949    let tokens = crate::core::tokens::count_tokens(output);
950    let savings_pct = if original_tokens > 0 {
951        ((original_tokens.saturating_sub(tokens)) as f64 / original_tokens as f64 * 100.0).round()
952            as i64
953    } else {
954        0
955    };
956    serde_json::json!({
957        "output": output,
958        "tokens": tokens,
959        "savings_pct": savings_pct
960    })
961}
962
963fn compression_demo_modes_json(
964    content: &str,
965    path: &str,
966    ext: &str,
967    original_tokens: usize,
968    task: Option<&str>,
969) -> serde_json::Value {
970    let map_out = crate::core::signatures::extract_file_map(path, content);
971    let sig_out = crate::core::signatures::extract_signatures(content, ext)
972        .iter()
973        .map(super::core::signatures::Signature::to_compact)
974        .collect::<Vec<_>>()
975        .join("\n");
976    let aggressive_out = crate::core::filters::aggressive_filter(content);
977    let entropy_out = crate::core::entropy::entropy_compress_adaptive(content, path).output;
978
979    let mut cache = crate::core::cache::SessionCache::new();
980    let reference_out =
981        crate::tools::ctx_read::handle(&mut cache, path, "reference", crate::tools::CrpMode::Off);
982    let task_out = task.filter(|t| !t.trim().is_empty()).map(|t| {
983        crate::tools::ctx_read::handle_with_task(
984            &mut cache,
985            path,
986            "task",
987            crate::tools::CrpMode::Off,
988            Some(t),
989        )
990    });
991
992    serde_json::json!({
993        "map": compression_mode_json(&map_out, original_tokens),
994        "signatures": compression_mode_json(&sig_out, original_tokens),
995        "reference": compression_mode_json(&reference_out, original_tokens),
996        "aggressive": compression_mode_json(&aggressive_out, original_tokens),
997        "entropy": compression_mode_json(&entropy_out, original_tokens),
998        "task": task_out.as_deref().map_or(serde_json::Value::Null, |s| compression_mode_json(s, original_tokens)),
999    })
1000}
1001
1002fn bm25_index_summary_json(index: &crate::core::vector_index::BM25Index) -> serde_json::Value {
1003    let mut sorted: Vec<&crate::core::vector_index::CodeChunk> = index.chunks.iter().collect();
1004    sorted.sort_by_key(|c| std::cmp::Reverse(c.token_count));
1005    let top: Vec<serde_json::Value> = sorted
1006        .into_iter()
1007        .take(20)
1008        .map(|c| {
1009            serde_json::json!({
1010                "file_path": c.file_path,
1011                "symbol_name": c.symbol_name,
1012                "token_count": c.token_count,
1013                "kind": c.kind,
1014                "start_line": c.start_line,
1015                "end_line": c.end_line,
1016            })
1017        })
1018        .collect();
1019    let mut lang: HashMap<String, usize> = HashMap::new();
1020    for c in &index.chunks {
1021        let e = std::path::Path::new(&c.file_path)
1022            .extension()
1023            .and_then(|e| e.to_str())
1024            .unwrap_or("")
1025            .to_string();
1026        *lang.entry(e).or_default() += 1;
1027    }
1028    serde_json::json!({
1029        "doc_count": index.doc_count,
1030        "chunk_count": index.chunks.len(),
1031        "top_chunks_by_token_count": top,
1032        "language_distribution": lang,
1033    })
1034}
1035
1036fn build_symbols_json(
1037    index: &crate::core::graph_index::ProjectIndex,
1038    query: Option<&str>,
1039    kind: Option<&str>,
1040) -> String {
1041    let query = query
1042        .map(|q| q.trim().to_lowercase())
1043        .filter(|q| !q.is_empty());
1044    let kind = kind
1045        .map(|k| k.trim().to_lowercase())
1046        .filter(|k| !k.is_empty());
1047
1048    let mut symbols: Vec<&crate::core::graph_index::SymbolEntry> = index
1049        .symbols
1050        .values()
1051        .filter(|sym| {
1052            let kind_match = match kind.as_ref() {
1053                Some(k) => sym.kind.eq_ignore_ascii_case(k),
1054                None => true,
1055            };
1056            let query_match = match query.as_ref() {
1057                Some(q) => {
1058                    let name = sym.name.to_lowercase();
1059                    let file = sym.file.to_lowercase();
1060                    let symbol_kind = sym.kind.to_lowercase();
1061                    name.contains(q) || file.contains(q) || symbol_kind.contains(q)
1062                }
1063                None => true,
1064            };
1065            kind_match && query_match
1066        })
1067        .collect();
1068
1069    symbols.sort_by(|a, b| {
1070        a.file
1071            .cmp(&b.file)
1072            .then_with(|| a.start_line.cmp(&b.start_line))
1073            .then_with(|| a.name.cmp(&b.name))
1074    });
1075    symbols.truncate(500);
1076
1077    serde_json::to_string(
1078        &symbols
1079            .into_iter()
1080            .map(|sym| {
1081                serde_json::json!({
1082                    "name": sym.name,
1083                    "kind": sym.kind,
1084                    "file": sym.file,
1085                    "start_line": sym.start_line,
1086                    "end_line": sym.end_line,
1087                    "is_exported": sym.is_exported,
1088                })
1089            })
1090            .collect::<Vec<_>>(),
1091    )
1092    .unwrap_or_else(|_| "[]".to_string())
1093}
1094
1095fn build_heatmap_json(index: &crate::core::graph_index::ProjectIndex) -> String {
1096    let mut connection_counts: std::collections::HashMap<String, usize> =
1097        std::collections::HashMap::new();
1098    for edge in &index.edges {
1099        *connection_counts.entry(edge.from.clone()).or_default() += 1;
1100        *connection_counts.entry(edge.to.clone()).or_default() += 1;
1101    }
1102
1103    let max_tokens = index
1104        .files
1105        .values()
1106        .map(|f| f.token_count)
1107        .max()
1108        .unwrap_or(1) as f64;
1109    let max_connections = connection_counts.values().max().copied().unwrap_or(1) as f64;
1110
1111    let mut entries: Vec<serde_json::Value> = index
1112        .files
1113        .values()
1114        .map(|f| {
1115            let connections = connection_counts.get(&f.path).copied().unwrap_or(0);
1116            let token_norm = f.token_count as f64 / max_tokens;
1117            let conn_norm = connections as f64 / max_connections;
1118            let heat = token_norm * 0.4 + conn_norm * 0.6;
1119            serde_json::json!({
1120                "path": f.path,
1121                "tokens": f.token_count,
1122                "connections": connections,
1123                "language": f.language,
1124                "heat": (heat * 100.0).round() / 100.0,
1125            })
1126        })
1127        .collect();
1128
1129    entries.sort_by(|a, b| {
1130        b["heat"]
1131            .as_f64()
1132            .unwrap_or(0.0)
1133            .partial_cmp(&a["heat"].as_f64().unwrap_or(0.0))
1134            .unwrap_or(std::cmp::Ordering::Equal)
1135    });
1136
1137    serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
1138}
1139
1140fn build_agents_json() -> String {
1141    let registry = crate::core::agents::AgentRegistry::load_or_create();
1142    let agents: Vec<serde_json::Value> = registry
1143        .agents
1144        .iter()
1145        .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
1146        .map(|a| {
1147            let age_min = (chrono::Utc::now() - a.last_active).num_minutes();
1148            serde_json::json!({
1149                "id": a.agent_id,
1150                "type": a.agent_type,
1151                "role": a.role,
1152                "status": format!("{}", a.status),
1153                "status_message": a.status_message,
1154                "last_active_minutes_ago": age_min,
1155                "pid": a.pid
1156            })
1157        })
1158        .collect();
1159
1160    let pending_msgs = registry.scratchpad.len();
1161
1162    let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
1163        .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".lean-ctx"))
1164        .join("agents")
1165        .join("shared");
1166    let shared_count = if shared_dir.exists() {
1167        std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
1168    } else {
1169        0
1170    };
1171
1172    serde_json::json!({
1173        "agents": agents,
1174        "total_active": agents.len(),
1175        "pending_messages": pending_msgs,
1176        "shared_contexts": shared_count
1177    })
1178    .to_string()
1179}
1180
1181fn detect_project_root_for_dashboard() -> String {
1182    if let Ok(explicit) = std::env::var("LEAN_CTX_DASHBOARD_PROJECT") {
1183        if !explicit.trim().is_empty() {
1184            return promote_to_git_root(&explicit);
1185        }
1186    }
1187
1188    if let Some(session) = crate::core::session::SessionState::load_latest() {
1189        // Try project_root first, but only if it resolves to a real project (has .git or markers).
1190        // MCP sessions often set project_root to a temp sandbox directory that contains no code.
1191        if let Some(root) = session.project_root.as_deref() {
1192            if !root.trim().is_empty() {
1193                if let Some(git_root) = git_root_for(root) {
1194                    return git_root;
1195                }
1196                if is_real_project(root) {
1197                    return root.to_string();
1198                }
1199            }
1200        }
1201        if let Some(cwd) = session.shell_cwd.as_deref() {
1202            if !cwd.trim().is_empty() {
1203                let r = crate::core::protocol::detect_project_root_or_cwd(cwd);
1204                return promote_to_git_root(&r);
1205            }
1206        }
1207        if let Some(last) = session.files_touched.last() {
1208            if !last.path.trim().is_empty() {
1209                if let Some(parent) = Path::new(&last.path).parent() {
1210                    let p = parent.to_string_lossy().to_string();
1211                    let r = crate::core::protocol::detect_project_root_or_cwd(&p);
1212                    return promote_to_git_root(&r);
1213                }
1214            }
1215        }
1216    }
1217
1218    let cwd = std::env::current_dir()
1219        .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
1220    let r = crate::core::protocol::detect_project_root_or_cwd(&cwd);
1221    promote_to_git_root(&r)
1222}
1223
1224fn is_real_project(path: &str) -> bool {
1225    let p = Path::new(path);
1226    if !p.is_dir() {
1227        return false;
1228    }
1229    const MARKERS: &[&str] = &[
1230        ".git",
1231        "Cargo.toml",
1232        "package.json",
1233        "go.mod",
1234        "pyproject.toml",
1235        "requirements.txt",
1236        "pom.xml",
1237        "build.gradle",
1238        "CMakeLists.txt",
1239        ".lean-ctx.toml",
1240    ];
1241    MARKERS.iter().any(|m| p.join(m).exists())
1242}
1243
1244fn promote_to_git_root(path: &str) -> String {
1245    git_root_for(path).unwrap_or_else(|| path.to_string())
1246}
1247
1248fn git_root_for(path: &str) -> Option<String> {
1249    let mut p = Path::new(path);
1250    loop {
1251        let git = p.join(".git");
1252        if git.exists() {
1253            return Some(p.to_string_lossy().to_string());
1254        }
1255        p = p.parent()?;
1256    }
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261    use super::*;
1262    use tempfile::tempdir;
1263
1264    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1265
1266    #[test]
1267    fn check_auth_with_valid_bearer() {
1268        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
1269        assert!(check_auth(req, "lctx_abc123"));
1270    }
1271
1272    #[test]
1273    fn check_auth_with_invalid_bearer() {
1274        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
1275        assert!(!check_auth(req, "lctx_abc123"));
1276    }
1277
1278    #[test]
1279    fn check_auth_missing_header() {
1280        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
1281        assert!(!check_auth(req, "lctx_abc123"));
1282    }
1283
1284    #[test]
1285    fn check_auth_lowercase_bearer() {
1286        let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
1287        assert!(check_auth(req, "lctx_abc123"));
1288    }
1289
1290    #[test]
1291    fn query_token_parsing() {
1292        let raw_path = "/index.html?token=lctx_abc123&other=val";
1293        let idx = raw_path.find('?').unwrap();
1294        let qs = &raw_path[idx + 1..];
1295        let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
1296        assert_eq!(tok, Some("lctx_abc123"));
1297    }
1298
1299    #[test]
1300    fn api_path_detection() {
1301        assert!("/api/stats".starts_with("/api/"));
1302        assert!("/api/version".starts_with("/api/"));
1303        assert!(!"/".starts_with("/api/"));
1304        assert!(!"/index.html".starts_with("/api/"));
1305        assert!(!"/favicon.ico".starts_with("/api/"));
1306    }
1307
1308    #[test]
1309    fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
1310        let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
1311        assert_eq!(
1312            normalized,
1313            format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
1314        );
1315    }
1316
1317    #[test]
1318    fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
1319        let input = r"C:\repo\backend\list_tables.js";
1320        assert_eq!(normalize_dashboard_demo_path(input), input);
1321    }
1322
1323    #[test]
1324    fn normalize_dashboard_demo_path_preserves_unc_path() {
1325        let input = r"\\server\share\backend\list_tables.js";
1326        assert_eq!(normalize_dashboard_demo_path(input), input);
1327    }
1328
1329    #[test]
1330    fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
1331        assert_eq!(
1332            normalize_dashboard_demo_path("./src/main.rs"),
1333            "src/main.rs"
1334        );
1335        assert_eq!(
1336            normalize_dashboard_demo_path(r".\src\main.rs"),
1337            format!("src{}main.rs", std::path::MAIN_SEPARATOR)
1338        );
1339    }
1340
1341    #[test]
1342    fn api_profile_returns_json() {
1343        let (_status, _ct, body) = route_response("/api/profile", "", None, None);
1344        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1345        assert!(v.get("active_name").is_some(), "missing active_name");
1346        assert!(
1347            v.pointer("/profile/profile/name")
1348                .and_then(|n| n.as_str())
1349                .is_some(),
1350            "missing profile.profile.name"
1351        );
1352        assert!(v.get("available").and_then(|a| a.as_array()).is_some());
1353    }
1354
1355    #[test]
1356    fn api_episodes_returns_json() {
1357        let (_status, _ct, body) = route_response("/api/episodes", "", None, None);
1358        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1359        assert!(v.get("project_hash").is_some());
1360        assert!(v.get("stats").is_some());
1361        assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
1362    }
1363
1364    #[test]
1365    fn api_procedures_returns_json() {
1366        let (_status, _ct, body) = route_response("/api/procedures", "", None, None);
1367        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1368        assert!(v.get("project_hash").is_some());
1369        assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
1370        assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
1371    }
1372
1373    #[test]
1374    fn api_compression_demo_heals_moved_file_paths() {
1375        let _g = ENV_LOCK.lock().expect("env lock");
1376        let td = tempdir().expect("tempdir");
1377        let root = td.path();
1378        std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
1379        std::fs::write(
1380            root.join("src").join("moved").join("foo.rs"),
1381            "pub fn foo() { println!(\"hi\"); }\n",
1382        )
1383        .expect("write foo.rs");
1384
1385        let root_s = root.to_string_lossy().to_string();
1386        std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
1387
1388        let (_status, _ct, body) =
1389            route_response("/api/compression-demo", "path=src/foo.rs", None, None);
1390        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1391        assert!(v.get("error").is_none(), "unexpected error: {body}");
1392        assert_eq!(
1393            v.get("resolved_from").and_then(|x| x.as_str()),
1394            Some("src/moved/foo.rs")
1395        );
1396
1397        std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
1398        if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
1399            let _ = std::fs::remove_dir_all(dir);
1400        }
1401    }
1402}