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_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
85fn 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
315fn 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
360fn 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
521fn 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}