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