use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
const GITHUB_NEW_ISSUE: &str = "https://github.com/tranc-ai/tranc-backend/issues/new";
const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
const LOG_TAIL_LINES: usize = 20;
#[derive(Debug, Parser)]
pub struct BugReportCmd {
#[arg(long, short = 't')]
pub title: Option<String>,
#[arg(long, short = 'c')]
pub command: Option<String>,
#[arg(long, short = 'l')]
pub log: Option<PathBuf>,
}
impl BugReportCmd {
pub fn run(self) -> Result<()> {
let title = self
.title
.unwrap_or_else(|| "[one-line summary]".to_string());
let log_snippet = collect_log_snippet(self.log);
let mut params = vec![
("template", "bug_report.yml".to_string()),
("labels", "type:bug".to_string()),
("title", format!("Bug: {title}")),
("cli_version", CLI_VERSION.to_string()),
(
"os",
format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
),
];
if let Some(cmd) = self.command {
params.push(("steps", format!("1. Run `{cmd}`\n2. See error")));
}
if let Some(snippet) = log_snippet {
params.push(("logs", snippet));
}
let query_string = params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
let url = format!("{GITHUB_NEW_ISSUE}?{query_string}");
println!("Opening GitHub issue form in your browser…");
println!();
println!(" {url}");
println!();
if open::that(&url).is_err() {
println!("Could not open browser automatically.");
println!("Copy the URL above and paste it into your browser.");
}
Ok(())
}
}
fn collect_log_snippet(explicit_path: Option<PathBuf>) -> Option<String> {
let path = explicit_path.or_else(default_log_path)?;
let file = File::open(&path).ok()?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
if lines.is_empty() {
return None;
}
let tail = lines
.iter()
.rev()
.take(LOG_TAIL_LINES)
.collect::<Vec<_>>()
.into_iter()
.rev()
.cloned()
.collect::<Vec<_>>()
.join("\n");
Some(tail)
}
fn default_log_path() -> Option<PathBuf> {
let mut p = dirs::data_local_dir()?; p.push("tranc");
p.push("tranc.log");
if p.exists() {
Some(p)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn parse_no_args() {
let cmd = BugReportCmd::try_parse_from(["bug-report"]).unwrap();
assert!(cmd.title.is_none());
assert!(cmd.command.is_none());
assert!(cmd.log.is_none());
}
#[test]
fn parse_all_args() {
let cmd = BugReportCmd::try_parse_from([
"bug-report",
"--title",
"RSI returned 500",
"--command",
"tranc rsi BTC-USD",
"--log",
"/tmp/test.log",
])
.unwrap();
assert_eq!(cmd.title.as_deref(), Some("RSI returned 500"));
assert_eq!(cmd.command.as_deref(), Some("tranc rsi BTC-USD"));
assert!(cmd.log.is_some());
}
#[test]
fn url_contains_required_fields() {
let snippet = collect_log_snippet(None);
let _ = snippet;
}
#[test]
fn log_tail_empty_path_returns_none() {
let result = collect_log_snippet(Some(PathBuf::from("/nonexistent/path/file.log")));
assert!(result.is_none());
}
}