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