Skip to main content

lean_ctx/dashboard/
mod.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4use tokio::io::{AsyncReadExt, AsyncWriteExt};
5use tokio::net::TcpListener;
6
7const DEFAULT_PORT: u16 = 3333;
8const DEFAULT_HOST: &str = "127.0.0.1";
9const DASHBOARD_HTML: &str = include_str!("dashboard.html");
10
11pub async fn start(port: Option<u16>, host: Option<String>) {
12    let port = port.unwrap_or_else(|| {
13        std::env::var("LEAN_CTX_PORT")
14            .ok()
15            .and_then(|p| p.parse().ok())
16            .unwrap_or(DEFAULT_PORT)
17    });
18
19    let host = host.unwrap_or_else(|| {
20        std::env::var("LEAN_CTX_HOST")
21            .ok()
22            .unwrap_or_else(|| DEFAULT_HOST.to_string())
23    });
24
25    let addr = format!("{host}:{port}");
26    let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
27
28    // Avoid accidental multiple dashboard instances (common source of "it hangs").
29    // Only safe to auto-detect for local dashboards without auth.
30    if is_local && dashboard_responding(&host, port) {
31        println!("\n  lean-ctx dashboard already running → http://{host}:{port}");
32        println!("  Tip: use Ctrl+C in the existing terminal to stop it.\n");
33        open_browser(&format!("http://localhost:{port}"));
34        return;
35    }
36
37    let token = if !is_local {
38        let t = generate_token();
39        save_token(&t);
40        Some(Arc::new(t))
41    } else {
42        None
43    };
44
45    if !is_local {
46        let t = token.as_ref().unwrap();
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    let listener = match TcpListener::bind(&addr).await {
55        Ok(l) => l,
56        Err(e) => {
57            eprintln!("Failed to bind to {addr}: {e}");
58            std::process::exit(1);
59        }
60    };
61
62    let stats_path = dirs::home_dir()
63        .map(|h| h.join(".lean-ctx/stats.json"))
64        .map(|p| p.display().to_string())
65        .unwrap_or_else(|| "~/.lean-ctx/stats.json".to_string());
66
67    if host == "0.0.0.0" {
68        println!("\n  lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
69        println!("  Local access:  http://localhost:{port}");
70    } else {
71        println!("\n  lean-ctx dashboard → http://{host}:{port}");
72    }
73    println!("  Stats file: {stats_path}");
74    println!("  Press Ctrl+C to stop\n");
75
76    if is_local {
77        open_browser(&format!("http://localhost:{port}"));
78    }
79
80    loop {
81        if let Ok((stream, _)) = listener.accept().await {
82            let token_ref = token.clone();
83            tokio::spawn(handle_request(stream, token_ref));
84        }
85    }
86}
87
88fn generate_token() -> String {
89    use std::time::{SystemTime, UNIX_EPOCH};
90    let seed = SystemTime::now()
91        .duration_since(UNIX_EPOCH)
92        .unwrap_or_default()
93        .as_nanos();
94    format!("lctx_{:016x}", seed ^ 0xdeadbeef_cafebabe)
95}
96
97fn save_token(token: &str) {
98    if let Some(dir) = dirs::home_dir().map(|h| h.join(".lean-ctx")) {
99        let _ = std::fs::create_dir_all(&dir);
100        let _ = std::fs::write(dir.join("dashboard.token"), token);
101    }
102}
103
104fn open_browser(url: &str) {
105    #[cfg(target_os = "macos")]
106    {
107        let _ = std::process::Command::new("open").arg(url).spawn();
108    }
109
110    #[cfg(target_os = "linux")]
111    {
112        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
113    }
114
115    #[cfg(target_os = "windows")]
116    {
117        let _ = std::process::Command::new("cmd")
118            .args(["/C", "start", url])
119            .spawn();
120    }
121}
122
123fn dashboard_responding(host: &str, port: u16) -> bool {
124    use std::io::{Read, Write};
125    use std::net::TcpStream;
126    use std::time::Duration;
127
128    let addr = format!("{host}:{port}");
129    let Ok(mut s) = TcpStream::connect_timeout(
130        &addr
131            .parse()
132            .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
133        Duration::from_millis(150),
134    ) else {
135        return false;
136    };
137    let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
138    let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
139
140    let req = "GET /api/version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
141    if s.write_all(req.as_bytes()).is_err() {
142        return false;
143    }
144    let mut buf = [0u8; 256];
145    let Ok(n) = s.read(&mut buf) else {
146        return false;
147    };
148    let head = String::from_utf8_lossy(&buf[..n]);
149    head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
150}
151
152async fn handle_request(mut stream: tokio::net::TcpStream, token: Option<Arc<String>>) {
153    let mut buf = vec![0u8; 4096];
154    let n = match stream.read(&mut buf).await {
155        Ok(n) if n > 0 => n,
156        _ => return,
157    };
158
159    let request = String::from_utf8_lossy(&buf[..n]);
160
161    let raw_path = request
162        .lines()
163        .next()
164        .and_then(|line| line.split_whitespace().nth(1))
165        .unwrap_or("/");
166
167    let (path, query_token) = if let Some(idx) = raw_path.find('?') {
168        let p = &raw_path[..idx];
169        let qs = &raw_path[idx + 1..];
170        let tok = qs
171            .split('&')
172            .find_map(|pair| pair.strip_prefix("token="))
173            .map(|t| t.to_string());
174        (p.to_string(), tok)
175    } else {
176        (raw_path.to_string(), None)
177    };
178
179    let query_str = raw_path.find('?').map(|i| &raw_path[i + 1..]).unwrap_or("");
180
181    let is_api = path.starts_with("/api/");
182
183    if let Some(ref expected) = token {
184        let has_header_auth = check_auth(&request, expected);
185        let has_query_auth = query_token
186            .as_deref()
187            .map(|t| t == expected.as_str())
188            .unwrap_or(false);
189
190        if is_api && !has_header_auth && !has_query_auth {
191            let body = r#"{"error":"unauthorized"}"#;
192            let response = format!(
193                "HTTP/1.1 401 Unauthorized\r\n\
194                 Content-Type: application/json\r\n\
195                 Content-Length: {}\r\n\
196                 WWW-Authenticate: Bearer\r\n\
197                 Connection: close\r\n\
198                 \r\n\
199                 {body}",
200                body.len()
201            );
202            let _ = stream.write_all(response.as_bytes()).await;
203            return;
204        }
205    }
206
207    let path = path.as_str();
208
209    let (status, content_type, body) = match path {
210        "/api/stats" => {
211            let store = crate::core::stats::load();
212            let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
213            ("200 OK", "application/json", json)
214        }
215        "/api/mcp" => {
216            let mcp_path = dirs::home_dir()
217                .map(|h| h.join(".lean-ctx").join("mcp-live.json"))
218                .unwrap_or_default();
219            let json = std::fs::read_to_string(&mcp_path).unwrap_or_else(|_| "{}".to_string());
220            ("200 OK", "application/json", json)
221        }
222        "/api/agents" => {
223            let json = build_agents_json();
224            ("200 OK", "application/json", json)
225        }
226        "/api/knowledge" => {
227            let project_root = detect_project_root_for_dashboard();
228            let _ =
229                crate::core::knowledge::ProjectKnowledge::migrate_legacy_empty_root(&project_root);
230            let knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
231            let json = serde_json::to_string(&knowledge).unwrap_or_else(|_| "{}".to_string());
232            ("200 OK", "application/json", json)
233        }
234        "/api/gotchas" => {
235            let project_root = detect_project_root_for_dashboard();
236            let store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
237            let json = serde_json::to_string(&store).unwrap_or_else(|_| "{}".to_string());
238            ("200 OK", "application/json", json)
239        }
240        "/api/buddy" => {
241            let buddy = crate::core::buddy::BuddyState::compute();
242            let json = serde_json::to_string(&buddy).unwrap_or_else(|_| "{}".to_string());
243            ("200 OK", "application/json", json)
244        }
245        "/api/version" => {
246            let json = crate::core::version_check::version_info_json();
247            ("200 OK", "application/json", json)
248        }
249        "/api/heatmap" => {
250            let project_root = detect_project_root_for_dashboard();
251            let index = crate::core::graph_index::load_or_build(&project_root);
252            let entries = build_heatmap_json(&index);
253            ("200 OK", "application/json", entries)
254        }
255        "/api/events" => {
256            let evs = crate::core::events::load_events_from_file(200);
257            let json = serde_json::to_string(&evs).unwrap_or_else(|_| "[]".to_string());
258            ("200 OK", "application/json", json)
259        }
260        "/api/graph" => {
261            let root = detect_project_root_for_dashboard();
262            let index = crate::core::graph_index::load_or_build(&root);
263            let json = serde_json::to_string(&index).unwrap_or_else(|_| {
264                "{\"error\":\"failed to serialize project index\"}".to_string()
265            });
266            ("200 OK", "application/json", json)
267        }
268        "/api/feedback" => {
269            let store = crate::core::feedback::FeedbackStore::load();
270            let json = serde_json::to_string(&store).unwrap_or_else(|_| {
271                "{\"error\":\"failed to serialize feedback store\"}".to_string()
272            });
273            ("200 OK", "application/json", json)
274        }
275        "/api/session" => {
276            let session = crate::core::session::SessionState::load_latest().unwrap_or_default();
277            let json = serde_json::to_string(&session)
278                .unwrap_or_else(|_| "{\"error\":\"failed to serialize session\"}".to_string());
279            ("200 OK", "application/json", json)
280        }
281        "/api/search-index" => {
282            let root_s = detect_project_root_for_dashboard();
283            let root = std::path::Path::new(&root_s);
284            let index = crate::core::vector_index::BM25Index::load_or_build(root);
285            let summary = bm25_index_summary_json(&index);
286            let json = serde_json::to_string(&summary).unwrap_or_else(|_| {
287                "{\"error\":\"failed to serialize search index summary\"}".to_string()
288            });
289            ("200 OK", "application/json", json)
290        }
291        "/api/symbols" => {
292            let root = detect_project_root_for_dashboard();
293            let query = extract_query_param(query_str, "q").unwrap_or_default();
294            let kind = extract_query_param(query_str, "kind");
295            let index = crate::core::graph_index::load_or_build(&root);
296            let mut results: Vec<_> = index
297                .symbols
298                .values()
299                .filter(|s| {
300                    let name_match =
301                        query.is_empty() || s.name.to_lowercase().contains(&query.to_lowercase());
302                    let kind_match = kind
303                        .as_ref()
304                        .map(|k| s.kind.to_lowercase() == k.to_lowercase())
305                        .unwrap_or(true);
306                    name_match && kind_match
307                })
308                .collect();
309            results.sort_by(|a, b| a.name.cmp(&b.name));
310            results.truncate(100);
311            let json = serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string());
312            ("200 OK", "application/json", json)
313        }
314        "/api/call-graph" => {
315            let root = detect_project_root_for_dashboard();
316            let index = crate::core::graph_index::load_or_build(&root);
317            let cg = crate::core::call_graph::CallGraph::load_or_build(&root, &index);
318            let _ = cg.save();
319            let json = serde_json::to_string(&cg).unwrap_or_else(|_| "{\"edges\":[]}".to_string());
320            ("200 OK", "application/json", json)
321        }
322        "/api/routes" => {
323            let root = detect_project_root_for_dashboard();
324            let index = crate::core::graph_index::load_or_build(&root);
325            let routes =
326                crate::core::route_extractor::extract_routes_from_project(&root, &index.files);
327            let json = serde_json::to_string(&routes).unwrap_or_else(|_| "[]".to_string());
328            ("200 OK", "application/json", json)
329        }
330        "/api/compression-demo" => {
331            let body = match extract_query_param(query_str, "path") {
332                None => r#"{"error":"missing path query parameter"}"#.to_string(),
333                Some(rel) => {
334                    let root = detect_project_root_for_dashboard();
335                    let root_pb = std::path::Path::new(&root);
336                    let candidate = std::path::Path::new(&rel);
337                    let full = if candidate.is_absolute() {
338                        candidate.to_path_buf()
339                    } else {
340                        let direct = root_pb.join(&rel);
341                        if direct.exists() {
342                            direct
343                        } else {
344                            let in_rust = root_pb.join("rust").join(&rel);
345                            if in_rust.exists() {
346                                in_rust
347                            } else {
348                                direct
349                            }
350                        }
351                    };
352                    match std::fs::read_to_string(&full) {
353                        Ok(content) => {
354                            let ext = full.extension().and_then(|e| e.to_str()).unwrap_or("rs");
355                            let path_str = full.to_string_lossy().to_string();
356                            let original_lines = content.lines().count();
357                            let original_tokens = crate::core::tokens::count_tokens(&content);
358                            let modes = compression_demo_modes_json(
359                                &content,
360                                &path_str,
361                                ext,
362                                original_tokens,
363                            );
364                            let original_preview: String = content.chars().take(8000).collect();
365                            serde_json::json!({
366                                "path": path_str,
367                                "original_lines": original_lines,
368                                "original_tokens": original_tokens,
369                                "original": original_preview,
370                                "modes": modes,
371                            })
372                            .to_string()
373                        }
374                        Err(_) => r#"{"error":"failed to read file"}"#.to_string(),
375                    }
376                }
377            };
378            ("200 OK", "application/json", body)
379        }
380        "/" | "/index.html" => {
381            let mut html = DASHBOARD_HTML.to_string();
382            if let Some(ref tok) = query_token {
383                let script = format!(
384                    "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
385                    tok.replace('"', "")
386                );
387                html = html.replacen("<head>", &format!("<head>{script}"), 1);
388            } else if let Some(ref t) = token {
389                let script = format!(
390                    "<script>window.__LEAN_CTX_TOKEN__=\"{}\";</script>",
391                    t.as_str()
392                );
393                html = html.replacen("<head>", &format!("<head>{script}"), 1);
394            }
395            ("200 OK", "text/html; charset=utf-8", html)
396        }
397        "/favicon.ico" => ("204 No Content", "text/plain", String::new()),
398        _ => ("404 Not Found", "text/plain", "Not Found".to_string()),
399    };
400
401    let cache_header = if content_type.starts_with("application/json") {
402        "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
403    } else {
404        ""
405    };
406
407    let response = format!(
408        "HTTP/1.1 {status}\r\n\
409         Content-Type: {content_type}\r\n\
410         Content-Length: {}\r\n\
411         {cache_header}\
412         Access-Control-Allow-Origin: *\r\n\
413         Connection: close\r\n\
414         \r\n\
415         {body}",
416        body.len()
417    );
418
419    let _ = stream.write_all(response.as_bytes()).await;
420}
421
422fn check_auth(request: &str, expected_token: &str) -> bool {
423    for line in request.lines() {
424        let lower = line.to_lowercase();
425        if lower.starts_with("authorization:") {
426            let value = line["authorization:".len()..].trim();
427            if let Some(token) = value.strip_prefix("Bearer ") {
428                return token.trim() == expected_token;
429            }
430            if let Some(token) = value.strip_prefix("bearer ") {
431                return token.trim() == expected_token;
432            }
433        }
434    }
435    false
436}
437
438fn extract_query_param(qs: &str, key: &str) -> Option<String> {
439    for pair in qs.split('&') {
440        let (k, v) = match pair.split_once('=') {
441            Some(kv) => kv,
442            None => continue,
443        };
444        if k == key {
445            return Some(percent_decode_query_component(v));
446        }
447    }
448    None
449}
450
451fn percent_decode_query_component(s: &str) -> String {
452    let mut out: Vec<u8> = Vec::with_capacity(s.len());
453    let b = s.as_bytes();
454    let mut i = 0;
455    while i < b.len() {
456        match b[i] {
457            b'+' => {
458                out.push(b' ');
459                i += 1;
460            }
461            b'%' if i + 2 < b.len() => {
462                let h1 = (b[i + 1] as char).to_digit(16);
463                let h2 = (b[i + 2] as char).to_digit(16);
464                if let (Some(a), Some(d)) = (h1, h2) {
465                    out.push(((a << 4) | d) as u8);
466                    i += 3;
467                } else {
468                    out.push(b'%');
469                    i += 1;
470                }
471            }
472            _ => {
473                out.push(b[i]);
474                i += 1;
475            }
476        }
477    }
478    String::from_utf8_lossy(&out).into_owned()
479}
480
481fn compression_mode_json(output: &str, original_tokens: usize) -> serde_json::Value {
482    let tokens = crate::core::tokens::count_tokens(output);
483    let savings_pct = if original_tokens > 0 {
484        ((original_tokens.saturating_sub(tokens)) as f64 / original_tokens as f64 * 100.0).round()
485            as i64
486    } else {
487        0
488    };
489    serde_json::json!({
490        "output": output,
491        "tokens": tokens,
492        "savings_pct": savings_pct
493    })
494}
495
496fn compression_demo_modes_json(
497    content: &str,
498    path: &str,
499    ext: &str,
500    original_tokens: usize,
501) -> serde_json::Value {
502    let map_out = crate::core::signatures::extract_file_map(path, content);
503    let sig_out = crate::core::signatures::extract_signatures(content, ext)
504        .iter()
505        .map(|s| s.to_compact())
506        .collect::<Vec<_>>()
507        .join("\n");
508    let aggressive_out = crate::core::filters::aggressive_filter(content);
509    let entropy_out = crate::core::entropy::entropy_compress_adaptive(content, path).output;
510    serde_json::json!({
511        "map": compression_mode_json(&map_out, original_tokens),
512        "signatures": compression_mode_json(&sig_out, original_tokens),
513        "aggressive": compression_mode_json(&aggressive_out, original_tokens),
514        "entropy": compression_mode_json(&entropy_out, original_tokens),
515    })
516}
517
518fn bm25_index_summary_json(index: &crate::core::vector_index::BM25Index) -> serde_json::Value {
519    let mut sorted: Vec<&crate::core::vector_index::CodeChunk> = index.chunks.iter().collect();
520    sorted.sort_by_key(|c| std::cmp::Reverse(c.token_count));
521    let top: Vec<serde_json::Value> = sorted
522        .into_iter()
523        .take(20)
524        .map(|c| {
525            serde_json::json!({
526                "file_path": c.file_path,
527                "symbol_name": c.symbol_name,
528                "token_count": c.token_count,
529                "kind": c.kind,
530                "start_line": c.start_line,
531                "end_line": c.end_line,
532            })
533        })
534        .collect();
535    let mut lang: HashMap<String, usize> = HashMap::new();
536    for c in &index.chunks {
537        let e = std::path::Path::new(&c.file_path)
538            .extension()
539            .and_then(|e| e.to_str())
540            .unwrap_or("")
541            .to_string();
542        *lang.entry(e).or_default() += 1;
543    }
544    serde_json::json!({
545        "doc_count": index.doc_count,
546        "chunk_count": index.chunks.len(),
547        "top_chunks_by_token_count": top,
548        "language_distribution": lang,
549    })
550}
551
552fn build_heatmap_json(index: &crate::core::graph_index::ProjectIndex) -> String {
553    let mut connection_counts: std::collections::HashMap<String, usize> =
554        std::collections::HashMap::new();
555    for edge in &index.edges {
556        *connection_counts.entry(edge.from.clone()).or_default() += 1;
557        *connection_counts.entry(edge.to.clone()).or_default() += 1;
558    }
559
560    let max_tokens = index
561        .files
562        .values()
563        .map(|f| f.token_count)
564        .max()
565        .unwrap_or(1) as f64;
566    let max_connections = connection_counts.values().max().copied().unwrap_or(1) as f64;
567
568    let mut entries: Vec<serde_json::Value> = index
569        .files
570        .values()
571        .map(|f| {
572            let connections = connection_counts.get(&f.path).copied().unwrap_or(0);
573            let token_norm = f.token_count as f64 / max_tokens;
574            let conn_norm = connections as f64 / max_connections;
575            let heat = token_norm * 0.4 + conn_norm * 0.6;
576            serde_json::json!({
577                "path": f.path,
578                "tokens": f.token_count,
579                "connections": connections,
580                "language": f.language,
581                "heat": (heat * 100.0).round() / 100.0,
582            })
583        })
584        .collect();
585
586    entries.sort_by(|a, b| {
587        b["heat"]
588            .as_f64()
589            .unwrap_or(0.0)
590            .partial_cmp(&a["heat"].as_f64().unwrap_or(0.0))
591            .unwrap()
592    });
593
594    serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
595}
596
597fn build_agents_json() -> String {
598    let registry = crate::core::agents::AgentRegistry::load_or_create();
599    let agents: Vec<serde_json::Value> = registry
600        .agents
601        .iter()
602        .filter(|a| a.status != crate::core::agents::AgentStatus::Finished)
603        .map(|a| {
604            let age_min = (chrono::Utc::now() - a.last_active).num_minutes();
605            serde_json::json!({
606                "id": a.agent_id,
607                "type": a.agent_type,
608                "role": a.role,
609                "status": format!("{}", a.status),
610                "status_message": a.status_message,
611                "last_active_minutes_ago": age_min,
612                "pid": a.pid
613            })
614        })
615        .collect();
616
617    let pending_msgs = registry.scratchpad.len();
618
619    let shared_dir = dirs::home_dir()
620        .unwrap_or_default()
621        .join(".lean-ctx")
622        .join("agents")
623        .join("shared");
624    let shared_count = if shared_dir.exists() {
625        std::fs::read_dir(&shared_dir)
626            .map(|rd| rd.count())
627            .unwrap_or(0)
628    } else {
629        0
630    };
631
632    serde_json::json!({
633        "agents": agents,
634        "total_active": agents.len(),
635        "pending_messages": pending_msgs,
636        "shared_contexts": shared_count
637    })
638    .to_string()
639}
640
641fn detect_project_root_for_dashboard() -> String {
642    // Prefer last known project context from the persisted session. This makes the dashboard
643    // show the same project data even if it is launched from an arbitrary working directory.
644    if let Some(session) = crate::core::session::SessionState::load_latest() {
645        if let Some(root) = session.project_root.as_deref() {
646            if !root.trim().is_empty() {
647                return promote_to_git_root(root);
648            }
649        }
650        if let Some(cwd) = session.shell_cwd.as_deref() {
651            if !cwd.trim().is_empty() {
652                let r = crate::core::protocol::detect_project_root_or_cwd(cwd);
653                return promote_to_git_root(&r);
654            }
655        }
656        if let Some(last) = session.files_touched.last() {
657            if !last.path.trim().is_empty() {
658                if let Some(parent) = Path::new(&last.path).parent() {
659                    let p = parent.to_string_lossy().to_string();
660                    let r = crate::core::protocol::detect_project_root_or_cwd(&p);
661                    return promote_to_git_root(&r);
662                }
663            }
664        }
665    }
666
667    let cwd = std::env::current_dir()
668        .map(|p| p.to_string_lossy().to_string())
669        .unwrap_or_else(|_| ".".to_string());
670    let r = crate::core::protocol::detect_project_root_or_cwd(&cwd);
671    promote_to_git_root(&r)
672}
673
674fn promote_to_git_root(path: &str) -> String {
675    git_root_for(path).unwrap_or_else(|| path.to_string())
676}
677
678fn git_root_for(path: &str) -> Option<String> {
679    let mut p = Path::new(path);
680    loop {
681        let git = p.join(".git");
682        if git.exists() {
683            return Some(p.to_string_lossy().to_string());
684        }
685        p = p.parent()?;
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn check_auth_with_valid_bearer() {
695        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
696        assert!(check_auth(req, "lctx_abc123"));
697    }
698
699    #[test]
700    fn check_auth_with_invalid_bearer() {
701        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
702        assert!(!check_auth(req, "lctx_abc123"));
703    }
704
705    #[test]
706    fn check_auth_missing_header() {
707        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
708        assert!(!check_auth(req, "lctx_abc123"));
709    }
710
711    #[test]
712    fn check_auth_lowercase_bearer() {
713        let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
714        assert!(check_auth(req, "lctx_abc123"));
715    }
716
717    #[test]
718    fn query_token_parsing() {
719        let raw_path = "/index.html?token=lctx_abc123&other=val";
720        let idx = raw_path.find('?').unwrap();
721        let qs = &raw_path[idx + 1..];
722        let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
723        assert_eq!(tok, Some("lctx_abc123"));
724    }
725
726    #[test]
727    fn api_path_detection() {
728        assert!("/api/stats".starts_with("/api/"));
729        assert!("/api/version".starts_with("/api/"));
730        assert!(!"/".starts_with("/api/"));
731        assert!(!"/index.html".starts_with("/api/"));
732        assert!(!"/favicon.ico".starts_with("/api/"));
733    }
734}