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