1use 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 crate::core::redaction::redact_text(&anonymize_report(&body))
88}
89
90fn 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 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
359fn 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
404fn 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 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
565fn 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}