Skip to main content

lean_ctx/
report.rs

1//! `lean-ctx report-issue` — collects diagnostics and creates a GitHub issue.
2
3use std::path::PathBuf;
4
5const VERSION: &str = env!("CARGO_PKG_VERSION");
6const REPO: &str = "yvgude/lean-ctx";
7const BOLD: &str = "\x1b[1m";
8const RST: &str = "\x1b[0m";
9const DIM: &str = "\x1b[2m";
10const GREEN: &str = "\x1b[32m";
11const YELLOW: &str = "\x1b[33m";
12
13pub fn run(args: &[String]) {
14    let title = extract_flag(args, "--title");
15    let description = extract_flag(args, "--description");
16    let dry_run = args.iter().any(|a| a == "--dry-run");
17    let include_tee = args.iter().any(|a| a == "--include-tee");
18
19    println!("{BOLD}lean-ctx report-issue{RST}\n");
20
21    let title = title.unwrap_or_else(|| prompt_input("Issue title"));
22    if title.trim().is_empty() {
23        eprintln!("Title is required. Aborting.");
24        std::process::exit(1);
25    }
26    let description = description.unwrap_or_else(|| prompt_input("Describe the problem"));
27
28    println!("\n{DIM}Collecting diagnostics...{RST}");
29    let body = build_report_body(&title, &description, include_tee);
30
31    println!("\n{BOLD}=== Preview ==={RST}\n");
32    let preview: String = body.chars().take(2000).collect();
33    println!("{preview}");
34    if body.len() > 2000 {
35        println!("{DIM}... ({} more characters){RST}", body.len() - 2000);
36    }
37
38    if dry_run {
39        println!("\n{YELLOW}--dry-run: not submitting.{RST}");
40        if let Some(dir) = lean_ctx_dir() {
41            let path = dir.join("last-report.md");
42            let _ = std::fs::write(&path, &body);
43            println!("Report saved to {}", path.display());
44        }
45        return;
46    }
47
48    println!("\n{BOLD}Submit this as a GitHub issue to {REPO}?{RST} [y/N]");
49    let mut answer = String::new();
50    let _ = std::io::stdin().read_line(&mut answer);
51    if !answer.trim().eq_ignore_ascii_case("y") {
52        println!("Aborted.");
53        if let Some(dir) = lean_ctx_dir() {
54            let path = dir.join("last-report.md");
55            let _ = std::fs::write(&path, &body);
56            println!("Report saved to {}", path.display());
57        }
58        return;
59    }
60
61    if try_gh_cli(&title, &body) {
62        return;
63    }
64    try_ureq_api(&title, &body);
65}
66
67fn build_report_body(_title: &str, description: &str, include_tee: bool) -> String {
68    let mut sections = Vec::new();
69
70    sections.push(format!("## Description\n\n{description}"));
71    sections.push(section_environment());
72    sections.push(section_recent_crashes());
73    sections.push(section_configuration());
74    sections.push(section_mcp_status());
75    sections.push(section_tool_calls());
76    sections.push(section_session());
77    sections.push(section_performance());
78    sections.push(section_slow_commands());
79    sections.push(section_tee_logs(include_tee));
80    sections.push(section_project_context());
81
82    let body = sections.join("\n\n---\n\n");
83    anonymize_report(&body)
84}
85
86// ── Section Builders ──────────────────────────────────────────────────────
87
88/// The last entries of the panic-hook crash log (`<data_dir>/logs/crash.log`).
89/// Without this, crash reports arrive with no location/payload and are not
90/// actionable (GitHub #386 shipped an empty report for a reproducible panic).
91fn section_recent_crashes() -> String {
92    let mut out = String::from("## Recent Crashes\n\n");
93    let log_path = crate::core::data_dir::lean_ctx_data_dir()
94        .ok()
95        .map(|d| d.join("logs").join("crash.log"));
96    let content = log_path
97        .as_ref()
98        .and_then(|p| std::fs::read_to_string(p).ok());
99    let Some(content) = content else {
100        out.push_str("No crash log found — no panics recorded on this machine.");
101        return out;
102    };
103
104    // Entries are separated by the `=== panic at` header; keep the newest 3
105    // and cap each backtrace so the report stays reviewable.
106    let entries: Vec<&str> = content
107        .split("=== panic at ")
108        .filter(|e| !e.trim().is_empty())
109        .collect();
110    if entries.is_empty() {
111        out.push_str("Crash log present but empty.");
112        return out;
113    }
114
115    out.push_str("```\n");
116    for entry in entries.iter().rev().take(3).rev() {
117        out.push_str("=== panic at ");
118        for line in entry.lines().take(14) {
119            out.push_str(line);
120            out.push('\n');
121        }
122        out.push_str("…\n\n");
123    }
124    out.push_str("```");
125    out
126}
127
128fn section_environment() -> String {
129    let os = std::env::consts::OS;
130    let arch = std::env::consts::ARCH;
131    let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".into());
132    let ide = detect_ide();
133
134    format!(
135        "## Environment\n\n\
136         | Field | Value |\n|---|---|\n\
137         | lean-ctx | {VERSION} |\n\
138         | OS | {os} {arch} |\n\
139         | Shell | {shell} |\n\
140         | IDE | {ide} |"
141    )
142}
143
144fn section_configuration() -> String {
145    let mut out = String::from("## Configuration\n\n```toml\n");
146    if let Some(dir) = lean_ctx_dir() {
147        let config_path = dir.join("config.toml");
148        if let Ok(content) = std::fs::read_to_string(&config_path) {
149            let clean = mask_secrets(&content);
150            out.push_str(&clean);
151        } else {
152            out.push_str("# config.toml not found — using defaults");
153        }
154    }
155    out.push_str("\n```");
156    out
157}
158
159fn section_mcp_status() -> String {
160    let mut lines = vec!["## MCP Integration Status\n".to_string()];
161
162    let binary_ok = which_lean_ctx().is_some();
163    lines.push(format!(
164        "- Binary on PATH: {}",
165        if binary_ok { "yes" } else { "no" }
166    ));
167
168    let hooks = check_shell_hooks();
169    lines.push(format!("- Shell hooks: {hooks}"));
170
171    let ides = check_mcp_configs();
172    lines.push(format!("- MCP configured for: {ides}"));
173
174    lines.join("\n")
175}
176
177fn section_tool_calls() -> String {
178    let mut out = String::from("## Recent Tool Calls\n\n```\n");
179    if let Some(dir) = lean_ctx_dir() {
180        let log_path = dir.join("tool-calls.log");
181        if let Ok(content) = std::fs::read_to_string(&log_path) {
182            let lines: Vec<&str> = content.lines().collect();
183            let start = lines.len().saturating_sub(20);
184            for line in &lines[start..] {
185                out.push_str(line);
186                out.push('\n');
187            }
188        } else {
189            out.push_str("# No tool call log found\n");
190        }
191    }
192    out.push_str("```");
193    out
194}
195
196fn section_session() -> String {
197    let mut out = String::from("## Session State\n\n");
198    if let Some(dir) = lean_ctx_dir() {
199        let latest = dir.join("sessions").join("latest.json");
200        if let Ok(content) = std::fs::read_to_string(&latest) {
201            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
202                if let Some(task) = val.get("task") {
203                    out.push_str(&format!(
204                        "- Task: {}\n",
205                        task.get("description")
206                            .and_then(|d| d.as_str())
207                            .unwrap_or("-")
208                    ));
209                }
210                if let Some(stats) = val.get("stats") {
211                    out.push_str(&format!("- Stats: {stats}\n"));
212                }
213                if let Some(files) = val.get("files_touched").and_then(|f| f.as_object()) {
214                    out.push_str(&format!("- Files touched: {}\n", files.len()));
215                }
216            }
217        } else {
218            out.push_str("No active session found.\n");
219        }
220    }
221    out
222}
223
224fn section_performance() -> String {
225    let mut out = String::from("## Performance Metrics\n\n");
226    if let Some(dir) = lean_ctx_dir() {
227        let mcp_live = dir.join("mcp-live.json");
228        if let Ok(content) = std::fs::read_to_string(&mcp_live) {
229            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
230                let fields = [
231                    "cep_score",
232                    "cache_utilization",
233                    "compression_rate",
234                    "tokens_saved",
235                    "tokens_original",
236                    "tool_calls",
237                ];
238                out.push_str("| Metric | Value |\n|---|---|\n");
239                for field in fields {
240                    if let Some(v) = val.get(field) {
241                        out.push_str(&format!("| {field} | {v} |\n"));
242                    }
243                }
244            }
245        }
246
247        let stats_path = dir.join("stats.json");
248        if let Ok(content) = std::fs::read_to_string(&stats_path) {
249            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
250                if let Some(cmds) = val.get("commands").and_then(|c| c.as_object()) {
251                    let mut top: Vec<_> = cmds
252                        .iter()
253                        .filter_map(|(k, v)| {
254                            v.get("count")
255                                .and_then(serde_json::Value::as_u64)
256                                .map(|c| (k, c))
257                        })
258                        .collect();
259                    top.sort_by_key(|x| std::cmp::Reverse(x.1));
260                    top.truncate(5);
261                    out.push_str("\n**Top 5 tools:**\n");
262                    for (name, count) in top {
263                        out.push_str(&format!("- {name}: {count} calls\n"));
264                    }
265                }
266            }
267        }
268    }
269    out
270}
271
272fn section_slow_commands() -> String {
273    let mut out = String::from("## Slow Commands\n\n```\n");
274    if let Some(dir) = lean_ctx_dir() {
275        let log_path = dir.join("slow-commands.log");
276        if let Ok(content) = std::fs::read_to_string(&log_path) {
277            let lines: Vec<&str> = content.lines().collect();
278            let start = lines.len().saturating_sub(10);
279            for line in &lines[start..] {
280                out.push_str(line);
281                out.push('\n');
282            }
283        } else {
284            out.push_str("# No slow commands logged\n");
285        }
286    }
287    out.push_str("```");
288    out
289}
290
291fn section_tee_logs(include_content: bool) -> String {
292    let mut out = String::from("## Tee Logs (last 24h)\n\n");
293    if let Some(dir) = lean_ctx_dir() {
294        let tee_dir = dir.join("tee");
295        if tee_dir.is_dir() {
296            let cutoff = std::time::SystemTime::now() - std::time::Duration::from_hours(24);
297            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
298                .into_iter()
299                .flatten()
300                .filter_map(std::result::Result::ok)
301                .filter(|e| {
302                    e.metadata()
303                        .ok()
304                        .and_then(|m| m.modified().ok())
305                        .is_some_and(|t| t > cutoff)
306                })
307                .collect();
308            entries.sort_by_key(|e| {
309                std::cmp::Reverse(
310                    e.metadata()
311                        .ok()
312                        .and_then(|m| m.modified().ok())
313                        .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
314                )
315            });
316
317            if entries.is_empty() {
318                out.push_str("No tee logs in the last 24h.\n");
319            } else {
320                for entry in entries.iter().take(10) {
321                    let name = entry.file_name();
322                    let size = entry.metadata().map_or(0, |m| m.len());
323                    out.push_str(&format!("- `{}` ({size} bytes)\n", name.to_string_lossy()));
324                }
325                if include_content {
326                    if let Some(latest) = entries.first() {
327                        if let Ok(content) = std::fs::read_to_string(latest.path()) {
328                            let truncated: String = content.chars().take(3000).collect();
329                            out.push_str(&format!(
330                                "\n**Latest tee content (`{}`):**\n```\n{truncated}\n```",
331                                latest.file_name().to_string_lossy()
332                            ));
333                        }
334                    }
335                }
336            }
337        } else {
338            out.push_str("No tee directory found.\n");
339        }
340    }
341    out
342}
343
344fn section_project_context() -> String {
345    let mut out = String::from("## Project Context\n\n");
346    let cwd = std::env::current_dir()
347        .map_or_else(|_| "unknown".into(), |p| p.to_string_lossy().to_string());
348    out.push_str(&format!("- Working directory: {cwd}\n"));
349
350    if let Ok(entries) = std::fs::read_dir(".") {
351        let count = entries.filter_map(std::result::Result::ok).count();
352        out.push_str(&format!("- Files in root: {count}\n"));
353    }
354    out
355}
356
357// ── Anonymization ─────────────────────────────────────────────────────────
358
359fn anonymize_report(text: &str) -> String {
360    let home = dirs::home_dir()
361        .map(|h| h.to_string_lossy().to_string())
362        .unwrap_or_default();
363
364    let mut result = text.to_string();
365    if !home.is_empty() {
366        result = result.replace(&home, "~");
367    }
368
369    let user = std::env::var("USER")
370        .or_else(|_| std::env::var("USERNAME"))
371        .unwrap_or_default();
372    if user.len() > 2 {
373        result = result.replace(&user, "<user>");
374    }
375
376    result
377}
378
379fn mask_secrets(text: &str) -> String {
380    let mut out = String::new();
381    for line in text.lines() {
382        if line.contains("token")
383            || line.contains("key")
384            || line.contains("secret")
385            || line.contains("password")
386            || line.contains("api_key")
387        {
388            if let Some(eq) = line.find('=') {
389                out.push_str(&line[..=eq]);
390                out.push_str(" \"[REDACTED]\"");
391            } else {
392                out.push_str(line);
393            }
394        } else {
395            out.push_str(line);
396        }
397        out.push('\n');
398    }
399    out
400}
401
402// ── GitHub Submission ─────────────────────────────────────────────────────
403
404fn find_gh_binary() -> Option<std::path::PathBuf> {
405    let candidates = [
406        "/opt/homebrew/bin/gh",
407        "/usr/local/bin/gh",
408        "/usr/bin/gh",
409        "/home/linuxbrew/.linuxbrew/bin/gh",
410    ];
411    for c in &candidates {
412        let p = std::path::Path::new(c);
413        if p.exists() {
414            return Some(p.to_path_buf());
415        }
416    }
417    if let Ok(output) = std::process::Command::new("which").arg("gh").output() {
418        if output.status.success() {
419            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
420            if !path.is_empty() {
421                return Some(std::path::PathBuf::from(path));
422            }
423        }
424    }
425    None
426}
427
428fn try_gh_cli(title: &str, body: &str) -> bool {
429    let Some(gh) = find_gh_binary() else {
430        return false;
431    };
432
433    let tmp = std::env::temp_dir().join("lean-ctx-report.md");
434    if std::fs::write(&tmp, body).is_err() {
435        return false;
436    }
437
438    let result = std::process::Command::new(&gh)
439        .args([
440            "issue",
441            "create",
442            "--repo",
443            REPO,
444            "--title",
445            title,
446            "--body-file",
447            &tmp.to_string_lossy(),
448            "--label",
449            "bug,auto-report",
450        ])
451        .output();
452
453    if let Ok(ref output) = result {
454        if !output.status.success() {
455            let stderr = String::from_utf8_lossy(&output.stderr);
456            if stderr.contains("not found") && stderr.contains("label") {
457                let _ = std::fs::remove_file(&tmp);
458                let fallback = std::process::Command::new(&gh)
459                    .args([
460                        "issue",
461                        "create",
462                        "--repo",
463                        REPO,
464                        "--title",
465                        title,
466                        "--body-file",
467                        &tmp.to_string_lossy(),
468                    ])
469                    .output();
470                let _ = std::fs::remove_file(&tmp);
471                if let Ok(fb_out) = fallback {
472                    if fb_out.status.success() {
473                        let url = String::from_utf8_lossy(&fb_out.stdout);
474                        println!("\n{GREEN}Issue created:{RST} {}", url.trim());
475                        return true;
476                    }
477                }
478                return false;
479            }
480        }
481    }
482
483    let _ = std::fs::remove_file(&tmp);
484
485    match result {
486        Ok(output) if output.status.success() => {
487            let url = String::from_utf8_lossy(&output.stdout);
488            println!("\n{GREEN}Issue created:{RST} {}", url.trim());
489            true
490        }
491        Ok(output) => {
492            let stderr = String::from_utf8_lossy(&output.stderr);
493            if stderr.contains("not logged") || stderr.contains("auth login") {
494                eprintln!("{YELLOW}gh CLI found but not authenticated. Run: gh auth login{RST}");
495            } else {
496                eprintln!("{YELLOW}gh issue create failed: {}{RST}", stderr.trim());
497            }
498            false
499        }
500        Err(e) => {
501            eprintln!("{YELLOW}Failed to run gh: {e}{RST}");
502            false
503        }
504    }
505}
506
507fn try_ureq_api(title: &str, body: &str) {
508    println!("\n{YELLOW}gh CLI not available. Using GitHub API directly.{RST}");
509    println!("Enter a GitHub Personal Access Token (needs 'repo' scope):");
510    println!("{DIM}Create one at: https://github.com/settings/tokens/new{RST}");
511
512    let mut token = String::new();
513    let _ = std::io::stdin().read_line(&mut token);
514    let token = token.trim();
515
516    if token.is_empty() {
517        eprintln!("No token provided. Saving report locally.");
518        save_report_locally(body);
519        return;
520    }
521
522    let url = format!("https://api.github.com/repos/{REPO}/issues");
523    let payload = serde_json::json!({
524        "title": title,
525        "body": body,
526        "labels": ["bug", "auto-report"]
527    });
528
529    let payload_bytes = serde_json::to_vec(&payload).unwrap_or_default();
530    match ureq::post(&url)
531        .header("Authorization", &format!("Bearer {token}"))
532        .header("Accept", "application/vnd.github.v3+json")
533        .header("Content-Type", "application/json")
534        .header("User-Agent", &format!("lean-ctx/{VERSION}"))
535        .send(payload_bytes.as_slice())
536    {
537        Ok(resp) => {
538            let resp_body = resp.into_body().read_to_string().unwrap_or_default();
539            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&resp_body) {
540                if let Some(html_url) = val.get("html_url").and_then(|u| u.as_str()) {
541                    println!("\n{GREEN}Issue created:{RST} {html_url}");
542                    return;
543                }
544            }
545            println!("{GREEN}Issue created successfully.{RST}");
546        }
547        Err(e) => {
548            eprintln!("GitHub API error: {e}");
549            save_report_locally(body);
550        }
551    }
552}
553
554fn save_report_locally(body: &str) {
555    if let Some(dir) = lean_ctx_dir() {
556        let path = dir.join("last-report.md");
557        let _ = std::fs::write(&path, body);
558        println!("Report saved to {}", path.display());
559    }
560}
561
562// ── Helpers ───────────────────────────────────────────────────────────────
563
564fn lean_ctx_dir() -> Option<PathBuf> {
565    dirs::home_dir().map(|h| h.join(".lean-ctx"))
566}
567
568fn which_lean_ctx() -> Option<PathBuf> {
569    let cmd = if cfg!(windows) { "where" } else { "which" };
570    std::process::Command::new(cmd)
571        .arg("lean-ctx")
572        .output()
573        .ok()
574        .filter(|o| o.status.success())
575        .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string()))
576}
577
578fn check_shell_hooks() -> String {
579    let Some(home) = dirs::home_dir() else {
580        return "unknown".into();
581    };
582
583    let mut found = Vec::new();
584    let shells = [
585        (".zshrc", "zsh"),
586        (".bashrc", "bash"),
587        (".config/fish/config.fish", "fish"),
588    ];
589    for (file, name) in shells {
590        let path = home.join(file);
591        if let Ok(content) = std::fs::read_to_string(&path) {
592            if content.contains("lean-ctx") {
593                found.push(name);
594            }
595        }
596    }
597
598    if found.is_empty() {
599        "none detected".into()
600    } else {
601        found.join(", ")
602    }
603}
604
605fn check_mcp_configs() -> String {
606    let Some(home) = dirs::home_dir() else {
607        return "unknown".into();
608    };
609
610    let mut found = Vec::new();
611    let claude_cfg = crate::setup::claude_config_json_path(&home);
612    let configs: Vec<(std::path::PathBuf, &str)> = vec![
613        (home.join(".cursor/mcp.json"), "Cursor"),
614        (claude_cfg, "Claude Code"),
615        (home.join(".codeium/windsurf/mcp_config.json"), "Windsurf"),
616    ];
617
618    for (full, name) in &configs {
619        if let Ok(content) = std::fs::read_to_string(full) {
620            if content.contains("lean-ctx") {
621                found.push(*name);
622            }
623        }
624    }
625
626    if found.is_empty() {
627        "none".into()
628    } else {
629        found.join(", ")
630    }
631}
632
633fn detect_ide() -> String {
634    if std::env::var("CURSOR_SESSION").is_ok() || std::env::var("CURSOR_TRACE_DIR").is_ok() {
635        return "Cursor".into();
636    }
637    if std::env::var("VSCODE_PID").is_ok() {
638        return "VS Code".into();
639    }
640    "unknown".into()
641}
642
643fn extract_flag(args: &[String], flag: &str) -> Option<String> {
644    args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
645}
646
647fn prompt_input(label: &str) -> String {
648    eprint!("{BOLD}{label}:{RST} ");
649    let mut input = String::new();
650    let _ = std::io::stdin().read_line(&mut input);
651    input.trim().to_string()
652}