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