shuire 0.2.0

Vim-like TUI git diff viewer
use clap::Parser;
use color_eyre::Result;

use crate::{app::App, cli::Args, config::Config, state::App as DiffApp};

mod action;
mod app;
mod cli;
mod components;
mod config;
mod diff;
mod errors;
mod event;
mod git;
mod highlight;
mod keys;
mod logging;
mod state;
mod storage;
mod theme;
mod tui;
mod ui;
mod word_diff;

#[tokio::main]
async fn main() -> Result<()> {
    crate::errors::init()?;
    crate::logging::init()?;

    let args = Args::parse();
    let user_config = Config::new().unwrap_or_default();
    let diff_app = build_diff_app(&args, &user_config)?;

    let mut app = App::new(args.tick_rate, args.frame_rate, diff_app, user_config)?;
    app.run().await?;
    let final_state = app.into_diff_state();

    let own_comments: Vec<_> = final_state
        .comments
        .iter()
        .filter(|c| !c.from_pr)
        .cloned()
        .collect();
    storage::save_comments(
        &own_comments,
        &final_state.range,
        &final_state.diff_fingerprint,
    );
    if !own_comments.is_empty() {
        println!("{}\n", final_state.format_all_comments());
    }
    Ok(())
}

fn build_diff_app(args: &Args, user_config: &Config) -> Result<DiffApp> {
    let theme_name = args.theme.or(user_config.theme).unwrap_or_default();
    let color_overrides = user_config.color_overrides();

    let source = detect_input_source(args);
    if let Err(msg) = cli::validate(args, source) {
        return Err(color_eyre::eyre::eyre!("{msg}"));
    }

    let (mut files, range) = match source {
        cli::InputSource::Pr => {
            let pr_url = args.pr.as_ref().expect("pr source implies --pr");
            // Resolve the head SHA up front so fold expansion can fetch blobs
            // at the PR's commit instead of the reviewer's working tree. A
            // failure here degrades gracefully: the diff still loads, but
            // expand-fold will surface a clear error.
            let head_sha = match git::load_pr_head_sha(pr_url) {
                Ok(sha) => Some(sha),
                Err(e) => {
                    eprintln!(
                        "[shuire] could not resolve PR head commit \
                         (fold expansion will be unavailable): {e}"
                    );
                    None
                }
            };
            let range = cli::DiffRange::PullRequest {
                url: pr_url.clone(),
                head_sha,
            };
            let diff_output = git::load_pr_diff(pr_url).map_err(anyhow_to_eyre)?;
            let files = diff::parse_unified(&diff_output);
            (files, range)
        }
        cli::InputSource::FromFile => {
            let path = args
                .from_file
                .as_ref()
                .expect("FromFile source implies --from-file");
            let input = std::fs::read_to_string(path)?;
            let range = cli::DiffRange::Stdin;
            let files = diff::parse_unified(&input);
            (files, range)
        }
        cli::InputSource::Stdin => {
            use std::io::Read;
            let mut input = String::new();
            std::io::stdin().read_to_string(&mut input)?;
            reattach_stdin_to_tty()?;
            let range = cli::DiffRange::Stdin;
            let files = diff::parse_unified(&input);
            (files, range)
        }
        cli::InputSource::Git => {
            let range = cli::resolve_range(args);
            let files = git::load_diff(&range, args).map_err(anyhow_to_eyre)?;
            (files, range)
        }
    };

    highlight::highlight_files(&mut files);

    if !args.auto_viewed.is_empty() {
        for file in &mut files {
            for pattern in &args.auto_viewed {
                if glob_match(pattern, &file.path) {
                    file.viewed = true;
                    break;
                }
            }
        }
    }

    let mut app = DiffApp::new(files, range.clone(), theme_name);
    app.color_overrides = color_overrides;
    app.no_emoji = args.no_emoji;
    app.comment_modal = user_config.comment_modal.unwrap_or(true);
    app.show_hints = args
        .hints_cli()
        .unwrap_or(user_config.show_hints.unwrap_or(true));
    if let Some(mode) = args.mode {
        app.split_view = matches!(mode, cli::ViewMode::Split);
    }

    if args.clean {
        storage::clear_comments(&range);
        eprintln!(
            "[shuire] --clean: cleared saved comments for {}",
            range.label()
        );
    } else {
        let persisted = storage::load_comments(&range, &app.diff_fingerprint);
        app.comments.extend(persisted);
    }

    for comment_json in &args.comment {
        match parse_comment_json(comment_json) {
            Ok(comments) => app.comments.extend(comments),
            Err(e) => eprintln!("[shuire] --comment: invalid JSON (ignored): {e}"),
        }
    }

    if let Some(ref pr_url) = args.pr {
        if !args.no_pr_comments {
            match git::load_pr_comments(pr_url) {
                Ok(pr_comments) => {
                    for pc in pr_comments {
                        if let Some(line) = pc.line {
                            app.comments.push(state::Comment {
                                file: pc.path,
                                side: state::Side::New,
                                lineno: line,
                                lineno_end: None,
                                body: pc.body,
                                replies: pc.replies,
                                from_pr: true,
                            });
                        }
                    }
                }
                Err(e) => eprintln!("[shuire] could not load PR review comments: {e}"),
            }
        }
    }

    Ok(app)
}

fn parse_comment_json(json: &str) -> Result<Vec<state::Comment>> {
    #[derive(serde::Deserialize)]
    struct RawComment {
        file: String,
        line: u32,
        body: String,
    }

    let raw: Vec<RawComment> = if json.trim().starts_with('[') {
        serde_json::from_str(json)?
    } else {
        vec![serde_json::from_str(json)?]
    };

    Ok(raw
        .into_iter()
        .map(|c| state::Comment {
            file: c.file,
            side: state::Side::New,
            lineno: c.line,
            lineno_end: None,
            body: c.body,
            replies: Vec::new(),
            from_pr: false,
        })
        .collect())
}

#[cfg(unix)]
fn reattach_stdin_to_tty() -> Result<()> {
    use std::os::fd::AsRawFd;
    let tty = std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .open("/dev/tty")
        .map_err(|e| color_eyre::eyre::eyre!("cannot open /dev/tty: {e}"))?;
    let ret = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) };
    if ret < 0 {
        return Err(color_eyre::eyre::eyre!(
            "dup2 /dev/tty -> stdin failed: {}",
            std::io::Error::last_os_error()
        ));
    }
    Ok(())
}

#[cfg(not(unix))]
fn reattach_stdin_to_tty() -> Result<()> {
    Ok(())
}

fn detect_input_source(args: &Args) -> cli::InputSource {
    if args.pr.is_some() {
        return cli::InputSource::Pr;
    }
    if args.from_file.is_some() {
        return cli::InputSource::FromFile;
    }
    if args.target == "-" {
        return cli::InputSource::Stdin;
    }
    let has_positional = args.target != "@" || args.compare_with.is_some();
    if !has_positional && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
        return cli::InputSource::Stdin;
    }
    cli::InputSource::Git
}

fn anyhow_to_eyre(err: anyhow::Error) -> color_eyre::eyre::Report {
    color_eyre::eyre::eyre!("{err:#}")
}

fn glob_match(pattern: &str, path: &str) -> bool {
    if pattern == "*" {
        return true;
    }
    if let Some(ext) = pattern.strip_prefix("*.") {
        return path.ends_with(&format!(".{ext}"));
    }
    if let Some(prefix) = pattern.strip_suffix("/**") {
        return path.starts_with(prefix);
    }
    path.contains(pattern)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn glob_match_star() {
        assert!(glob_match("*", "anything"));
        assert!(glob_match("*", ""));
    }

    #[test]
    fn glob_match_extension() {
        assert!(glob_match("*.rs", "src/main.rs"));
        assert!(glob_match("*.rs", "a.rs"));
        assert!(!glob_match("*.rs", "a.rst"));
        assert!(!glob_match("*.rs", "rs"));
    }

    #[test]
    fn glob_match_directory_prefix() {
        assert!(glob_match("vendor/**", "vendor/lib/foo.rs"));
        assert!(glob_match("vendor/**", "vendor/"));
        assert!(!glob_match("vendor/**", "src/lib/foo.rs"));
    }

    #[test]
    fn glob_match_substring_fallback() {
        assert!(glob_match("node_modules", "a/node_modules/b.js"));
        assert!(!glob_match("node_modules", "src/main.rs"));
    }

    #[test]
    fn parse_comment_json_single_object() {
        let cs = parse_comment_json(r#"{"file":"a.rs","line":10,"body":"hello"}"#).unwrap();
        assert_eq!(cs.len(), 1);
        assert_eq!(cs[0].file, "a.rs");
        assert_eq!(cs[0].lineno, 10);
        assert_eq!(cs[0].body, "hello");
        assert_eq!(cs[0].side, state::Side::New);
        assert!(cs[0].replies.is_empty());
    }

    #[test]
    fn parse_comment_json_array() {
        let json = r#"[
            {"file":"a.rs","line":1,"body":"x"},
            {"file":"b.rs","line":2,"body":"y"}
        ]"#;
        let cs = parse_comment_json(json).unwrap();
        assert_eq!(cs.len(), 2);
        assert_eq!(cs[0].file, "a.rs");
        assert_eq!(cs[1].file, "b.rs");
    }

    #[test]
    fn parse_comment_json_missing_fields_errors() {
        // Missing `body` — structurally invalid, should error (and be surfaced
        // to stderr by the caller) rather than silently being dropped.
        assert!(parse_comment_json(r#"{"file":"a.rs","line":1}"#).is_err());
        // Not JSON at all.
        assert!(parse_comment_json("garbage").is_err());
    }

    #[test]
    fn detect_input_source_pr_takes_priority() {
        let mut args = cli::Args {
            tick_rate: 4.0,
            frame_rate: 60.0,
            target: "@".to_string(),
            compare_with: None,
            merge_base: false,
            include_untracked: false,
            context: None,
            theme: None,
            pr: Some("https://github.com/a/b/pull/1".to_string()),
            from_file: Some("x".into()),
            comment: Vec::new(),
            auto_viewed: Vec::new(),
            no_emoji: false,
            mode: None,
            clean: false,
            hints: false,
            no_hints: false,
            no_pr_comments: false,
        };
        assert_eq!(detect_input_source(&args), cli::InputSource::Pr);
        args.pr = None;
        assert_eq!(detect_input_source(&args), cli::InputSource::FromFile);
    }

    #[test]
    fn detect_input_source_dash_forces_stdin() {
        let args = cli::Args {
            tick_rate: 4.0,
            frame_rate: 60.0,
            target: "-".to_string(),
            compare_with: None,
            merge_base: false,
            include_untracked: false,
            context: None,
            theme: None,
            pr: None,
            from_file: None,
            comment: Vec::new(),
            auto_viewed: Vec::new(),
            no_emoji: false,
            mode: None,
            clean: false,
            hints: false,
            no_hints: false,
            no_pr_comments: false,
        };
        assert_eq!(detect_input_source(&args), cli::InputSource::Stdin);
    }
}