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