tranc-cli 0.1.0

Tranc CLI — trade indicator queries from the command line.
//! `tranc bug-report` subcommand — open a pre-filled GitHub issue in the browser.
//!
//! Collects:
//!   - CLI version (from the Cargo-embedded version string)
//!   - OS and architecture
//!   - Optional one-line summary (`--title`)
//!   - Optional command that triggered the bug (`--command`)
//!   - Optional log snippet: `--log <file>` or the last 20 lines of the
//!     default log file (`~/.local/share/tranc/tranc.log`) if it exists.
//!
//! All data is URL-encoded into a GitHub issue "new" URL and opened in the
//! default browser.  If the browser launch fails the URL is printed to stdout
//! as a fallback.
//!
//! Resolves issue #72.

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");

/// Maximum number of log lines to include in the report.
const LOG_TAIL_LINES: usize = 20;

/// `tranc bug-report` — open a pre-filled GitHub bug report in the browser.
#[derive(Debug, Parser)]
pub struct BugReportCmd {
    /// One-line summary of the bug (used as the issue title).
    #[arg(long, short = 't')]
    pub title: Option<String>,

    /// The command you ran when you hit the bug (e.g. "tranc rsi BTC-USD --tf 5m").
    #[arg(long, short = 'c')]
    pub command: Option<String>,

    /// Path to a log file whose last lines should be attached (defaults to
    /// ~/.local/share/tranc/tranc.log if it exists).
    #[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 {
            // Prefix the command string into the steps-to-reproduce field so
            // it surfaces in a named template field.
            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(())
    }
}

/// Return the last [`LOG_TAIL_LINES`] lines from the given path, or from the
/// default log location if no path was provided.  Returns `None` if no log
/// file is found or the file is empty.
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);

    // Collect all lines then take the tail — files are small enough that this
    // is fine and avoids a seek-based approach.
    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)
}

/// Default log file path: `~/.local/share/tranc/tranc.log`.
fn default_log_path() -> Option<PathBuf> {
    let mut p = dirs::data_local_dir()?; // ~/.local/share on Linux, ~/Library/Application Support on macOS
    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() {
        // We test the URL-building logic indirectly by exercising collect_log_snippet.
        // Direct URL assertion would require running the command, which opens a browser.
        let snippet = collect_log_snippet(None);
        // Should be None when no log file exists in CI.
        // (Will be Some when the log file exists locally — that's fine.)
        let _ = snippet;
    }

    #[test]
    fn log_tail_empty_path_returns_none() {
        // Non-existent file should return None gracefully.
        let result = collect_log_snippet(Some(PathBuf::from("/nonexistent/path/file.log")));
        assert!(result.is_none());
    }
}