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 anonymize_report(&body)
84}
85
86fn 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 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 && let Ok(val) = serde_json::from_str::<serde_json::Value>(&content)
230 {
231 let fields = [
232 "cep_score",
233 "cache_utilization",
234 "compression_rate",
235 "tokens_saved",
236 "tokens_original",
237 "tool_calls",
238 ];
239 out.push_str("| Metric | Value |\n|---|---|\n");
240 for field in fields {
241 if let Some(v) = val.get(field) {
242 out.push_str(&format!("| {field} | {v} |\n"));
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 && let Ok(val) = serde_json::from_str::<serde_json::Value>(&content)
250 && let Some(cmds) = val.get("commands").and_then(|c| c.as_object())
251 {
252 let mut top: Vec<_> = cmds
253 .iter()
254 .filter_map(|(k, v)| {
255 v.get("count")
256 .and_then(serde_json::Value::as_u64)
257 .map(|c| (k, c))
258 })
259 .collect();
260 top.sort_by_key(|x| std::cmp::Reverse(x.1));
261 top.truncate(5);
262 out.push_str("\n**Top 5 tools:**\n");
263 for (name, count) in top {
264 out.push_str(&format!("- {name}: {count} calls\n"));
265 }
266 }
267 }
268 out
269}
270
271fn section_slow_commands() -> String {
272 let mut out = String::from("## Slow Commands\n\n```\n");
273 if let Some(dir) = lean_ctx_dir() {
274 let log_path = dir.join("slow-commands.log");
275 if let Ok(content) = std::fs::read_to_string(&log_path) {
276 let lines: Vec<&str> = content.lines().collect();
277 let start = lines.len().saturating_sub(10);
278 for line in &lines[start..] {
279 out.push_str(line);
280 out.push('\n');
281 }
282 } else {
283 out.push_str("# No slow commands logged\n");
284 }
285 }
286 out.push_str("```");
287 out
288}
289
290fn section_tee_logs(include_content: bool) -> String {
291 let mut out = String::from("## Tee Logs (last 24h)\n\n");
292 if let Some(dir) = lean_ctx_dir() {
293 let tee_dir = dir.join("tee");
294 if tee_dir.is_dir() {
295 let cutoff = std::time::SystemTime::now() - std::time::Duration::from_hours(24);
296 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
297 .into_iter()
298 .flatten()
299 .filter_map(std::result::Result::ok)
300 .filter(|e| {
301 e.metadata()
302 .ok()
303 .and_then(|m| m.modified().ok())
304 .is_some_and(|t| t > cutoff)
305 })
306 .collect();
307 entries.sort_by_key(|e| {
308 std::cmp::Reverse(
309 e.metadata()
310 .ok()
311 .and_then(|m| m.modified().ok())
312 .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
313 )
314 });
315
316 if entries.is_empty() {
317 out.push_str("No tee logs in the last 24h.\n");
318 } else {
319 for entry in entries.iter().take(10) {
320 let name = entry.file_name();
321 let size = entry.metadata().map_or(0, |m| m.len());
322 out.push_str(&format!("- `{}` ({size} bytes)\n", name.to_string_lossy()));
323 }
324 if include_content
325 && let Some(latest) = entries.first()
326 && let Ok(content) = std::fs::read_to_string(latest.path())
327 {
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 } else {
336 out.push_str("No tee directory found.\n");
337 }
338 }
339 out
340}
341
342fn section_project_context() -> String {
343 let mut out = String::from("## Project Context\n\n");
344 let cwd = std::env::current_dir()
345 .map_or_else(|_| "unknown".into(), |p| p.to_string_lossy().to_string());
346 out.push_str(&format!("- Working directory: {cwd}\n"));
347
348 if let Ok(entries) = std::fs::read_dir(".") {
349 let count = entries.filter_map(std::result::Result::ok).count();
350 out.push_str(&format!("- Files in root: {count}\n"));
351 }
352 out
353}
354
355fn anonymize_report(text: &str) -> String {
358 let home = dirs::home_dir()
359 .map(|h| h.to_string_lossy().to_string())
360 .unwrap_or_default();
361
362 let mut result = text.to_string();
363 if !home.is_empty() {
364 result = result.replace(&home, "~");
365 }
366
367 let user = std::env::var("USER")
368 .or_else(|_| std::env::var("USERNAME"))
369 .unwrap_or_default();
370 if user.len() > 2 {
371 result = result.replace(&user, "<user>");
372 }
373
374 result
375}
376
377fn mask_secrets(text: &str) -> String {
378 let mut out = String::new();
379 for line in text.lines() {
380 if line.contains("token")
381 || line.contains("key")
382 || line.contains("secret")
383 || line.contains("password")
384 || line.contains("api_key")
385 {
386 if let Some(eq) = line.find('=') {
387 out.push_str(&line[..=eq]);
388 out.push_str(" \"[REDACTED]\"");
389 } else {
390 out.push_str(line);
391 }
392 } else {
393 out.push_str(line);
394 }
395 out.push('\n');
396 }
397 out
398}
399
400fn find_gh_binary() -> Option<std::path::PathBuf> {
403 let candidates = [
404 "/opt/homebrew/bin/gh",
405 "/usr/local/bin/gh",
406 "/usr/bin/gh",
407 "/home/linuxbrew/.linuxbrew/bin/gh",
408 ];
409 for c in &candidates {
410 let p = std::path::Path::new(c);
411 if p.exists() {
412 return Some(p.to_path_buf());
413 }
414 }
415 if let Ok(output) = std::process::Command::new("which").arg("gh").output()
416 && output.status.success()
417 {
418 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
419 if !path.is_empty() {
420 return Some(std::path::PathBuf::from(path));
421 }
422 }
423 None
424}
425
426fn try_gh_cli(title: &str, body: &str) -> bool {
427 let Some(gh) = find_gh_binary() else {
428 return false;
429 };
430
431 let tmp = std::env::temp_dir().join("lean-ctx-report.md");
432 if std::fs::write(&tmp, body).is_err() {
433 return false;
434 }
435
436 let result = std::process::Command::new(&gh)
437 .args([
438 "issue",
439 "create",
440 "--repo",
441 REPO,
442 "--title",
443 title,
444 "--body-file",
445 &tmp.to_string_lossy(),
446 "--label",
447 "bug,auto-report",
448 ])
449 .output();
450
451 if let Ok(ref output) = result
452 && !output.status.success()
453 {
454 let stderr = String::from_utf8_lossy(&output.stderr);
455 if stderr.contains("not found") && stderr.contains("label") {
456 let _ = std::fs::remove_file(&tmp);
457 let fallback = std::process::Command::new(&gh)
458 .args([
459 "issue",
460 "create",
461 "--repo",
462 REPO,
463 "--title",
464 title,
465 "--body-file",
466 &tmp.to_string_lossy(),
467 ])
468 .output();
469 let _ = std::fs::remove_file(&tmp);
470 if let Ok(fb_out) = fallback
471 && fb_out.status.success()
472 {
473 let url = String::from_utf8_lossy(&fb_out.stdout);
474 println!("\n{GREEN}Issue created:{RST} {}", url.trim());
475 return true;
476 }
477 return false;
478 }
479 }
480
481 let _ = std::fs::remove_file(&tmp);
482
483 match result {
484 Ok(output) if output.status.success() => {
485 let url = String::from_utf8_lossy(&output.stdout);
486 println!("\n{GREEN}Issue created:{RST} {}", url.trim());
487 true
488 }
489 Ok(output) => {
490 let stderr = String::from_utf8_lossy(&output.stderr);
491 if stderr.contains("not logged") || stderr.contains("auth login") {
492 eprintln!("{YELLOW}gh CLI found but not authenticated. Run: gh auth login{RST}");
493 } else {
494 eprintln!("{YELLOW}gh issue create failed: {}{RST}", stderr.trim());
495 }
496 false
497 }
498 Err(e) => {
499 eprintln!("{YELLOW}Failed to run gh: {e}{RST}");
500 false
501 }
502 }
503}
504
505fn try_ureq_api(title: &str, body: &str) {
506 println!("\n{YELLOW}gh CLI not available. Using GitHub API directly.{RST}");
507 println!("Enter a GitHub Personal Access Token (needs 'repo' scope):");
508 println!("{DIM}Create one at: https://github.com/settings/tokens/new{RST}");
509
510 let mut token = String::new();
511 let _ = std::io::stdin().read_line(&mut token);
512 let token = token.trim();
513
514 if token.is_empty() {
515 eprintln!("No token provided. Saving report locally.");
516 save_report_locally(body);
517 return;
518 }
519
520 let url = format!("https://api.github.com/repos/{REPO}/issues");
521 let payload = serde_json::json!({
522 "title": title,
523 "body": body,
524 "labels": ["bug", "auto-report"]
525 });
526
527 let payload_bytes = serde_json::to_vec(&payload).unwrap_or_default();
528 match ureq::post(&url)
529 .header("Authorization", &format!("Bearer {token}"))
530 .header("Accept", "application/vnd.github.v3+json")
531 .header("Content-Type", "application/json")
532 .header("User-Agent", &format!("lean-ctx/{VERSION}"))
533 .send(payload_bytes.as_slice())
534 {
535 Ok(resp) => {
536 let resp_body = resp.into_body().read_to_string().unwrap_or_default();
537 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&resp_body)
538 && let Some(html_url) = val.get("html_url").and_then(|u| u.as_str())
539 {
540 println!("\n{GREEN}Issue created:{RST} {html_url}");
541 return;
542 }
543 println!("{GREEN}Issue created successfully.{RST}");
544 }
545 Err(e) => {
546 eprintln!("GitHub API error: {e}");
547 save_report_locally(body);
548 }
549 }
550}
551
552fn save_report_locally(body: &str) {
553 if let Some(dir) = lean_ctx_dir() {
554 let path = dir.join("last-report.md");
555 let _ = std::fs::write(&path, body);
556 println!("Report saved to {}", path.display());
557 }
558}
559
560fn lean_ctx_dir() -> Option<PathBuf> {
563 dirs::home_dir().map(|h| h.join(".lean-ctx"))
564}
565
566fn which_lean_ctx() -> Option<PathBuf> {
567 let cmd = if cfg!(windows) { "where" } else { "which" };
568 std::process::Command::new(cmd)
569 .arg("lean-ctx")
570 .output()
571 .ok()
572 .filter(|o| o.status.success())
573 .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string()))
574}
575
576fn check_shell_hooks() -> String {
577 let Some(home) = dirs::home_dir() else {
578 return "unknown".into();
579 };
580
581 let mut found = Vec::new();
582 let shells = [
583 (".zshrc", "zsh"),
584 (".bashrc", "bash"),
585 (".config/fish/config.fish", "fish"),
586 ];
587 for (file, name) in shells {
588 let path = home.join(file);
589 if let Ok(content) = std::fs::read_to_string(&path)
590 && content.contains("lean-ctx")
591 {
592 found.push(name);
593 }
594 }
595
596 if found.is_empty() {
597 "none detected".into()
598 } else {
599 found.join(", ")
600 }
601}
602
603fn check_mcp_configs() -> String {
604 let Some(home) = dirs::home_dir() else {
605 return "unknown".into();
606 };
607
608 let mut found = Vec::new();
609 let claude_cfg = crate::setup::claude_config_json_path(&home);
610 let codebuddy_cfg = crate::core::editor_registry::codebuddy_mcp_json_path(&home);
611 let configs: Vec<(std::path::PathBuf, &str)> = vec![
612 (home.join(".cursor/mcp.json"), "Cursor"),
613 (claude_cfg, "Claude Code"),
614 (codebuddy_cfg, "CodeBuddy"),
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 && content.contains("lean-ctx")
621 {
622 found.push(*name);
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}