shuire 0.1.1

Vim-like TUI git diff viewer
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use tracing::warn;

use crate::cli::DiffRange;
use crate::diff::FileDiff;
use crate::state::{Comment, Side};

#[derive(Serialize, Deserialize)]
struct StoredComment {
    file: String,
    side: Side,
    lineno: u32,
    lineno_end: Option<u32>,
    body: String,
    #[serde(default)]
    replies: Vec<String>,
}

fn storage_dir() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home).join(".shuire").join("comments")
}

fn range_key(range: &DiffRange) -> String {
    // Hash the repo path + diff range. Identifies the "scope" of comments
    // independent of the actual diff contents.
    let mut hasher = DefaultHasher::new();
    let repo_root = std::env::current_dir()
        .map(|p| p.display().to_string())
        .unwrap_or_default();
    repo_root.hash(&mut hasher);
    range.label().hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

/// Fingerprint of the diff payload itself. Two runs that produce byte-identical
/// diffs (same paths, same hunks, same line numbers) share a fingerprint;
/// any drift produces a different one so stale comments are not surfaced.
pub fn diff_fingerprint(files: &[FileDiff]) -> String {
    let mut hasher = DefaultHasher::new();
    for f in files {
        f.path.hash(&mut hasher);
        f.old_path.hash(&mut hasher);
        std::mem::discriminant(&f.status).hash(&mut hasher);
        for line in &f.lines {
            std::mem::discriminant(&line.kind).hash(&mut hasher);
            line.text.hash(&mut hasher);
            line.old_lineno.hash(&mut hasher);
            line.new_lineno.hash(&mut hasher);
        }
    }
    format!("{:016x}", hasher.finish())
}

fn storage_path(range: &DiffRange, fingerprint: &str) -> PathBuf {
    storage_dir().join(format!("{}-{}.json", range_key(range), fingerprint))
}

/// Remove every stored comment file for this range, regardless of which diff
/// fingerprint they were saved under. Used by `--clean` so older orphans for
/// the same range are also wiped.
pub fn clear_comments(range: &DiffRange) {
    clear_comments_in(&storage_dir(), range);
}

fn clear_comments_in(dir: &Path, range: &DiffRange) {
    let prefix = format!("{}-", range_key(range));
    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
        Err(e) => {
            warn!("storage: read_dir {dir:?} failed: {e}");
            return;
        }
    };
    for entry in entries.flatten() {
        let name = entry.file_name();
        if let Some(name) = name.to_str()
            && name.starts_with(&prefix)
            && name.ends_with(".json")
        {
            let _ = std::fs::remove_file(entry.path());
        }
    }
}

pub fn save_comments(comments: &[Comment], range: &DiffRange, fingerprint: &str) {
    save_comments_at(&storage_path(range, fingerprint), comments);
}

pub fn load_comments(range: &DiffRange, fingerprint: &str) -> Vec<Comment> {
    load_comments_at(&storage_path(range, fingerprint))
}

fn save_comments_at(path: &Path, comments: &[Comment]) {
    if comments.is_empty() {
        let _ = std::fs::remove_file(path);
        return;
    }

    let stored: Vec<StoredComment> = comments
        .iter()
        .map(|c| StoredComment {
            file: c.file.clone(),
            side: c.side,
            lineno: c.lineno,
            lineno_end: c.lineno_end,
            body: c.body.clone(),
            replies: c.replies.clone(),
        })
        .collect();

    if let Some(dir) = path.parent() {
        if let Err(e) = std::fs::create_dir_all(dir) {
            warn!("storage: create_dir_all {dir:?} failed: {e}");
            return;
        }
    }
    match serde_json::to_string_pretty(&stored) {
        Ok(json) => {
            if let Err(e) = std::fs::write(path, json) {
                warn!("storage: write {path:?} failed: {e}");
            }
        }
        Err(e) => warn!("storage: serialize comments failed: {e}"),
    }
}

fn load_comments_at(path: &Path) -> Vec<Comment> {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
        Err(e) => {
            warn!("storage: read {path:?} failed: {e}");
            return Vec::new();
        }
    };
    let stored: Vec<StoredComment> = match serde_json::from_str(&content) {
        Ok(s) => s,
        Err(e) => {
            warn!("storage: parse {path:?} failed: {e}");
            return Vec::new();
        }
    };
    stored
        .into_iter()
        .map(|s| Comment {
            file: s.file,
            side: s.side,
            lineno: s.lineno,
            lineno_end: s.lineno_end,
            body: s.body,
            replies: s.replies,
        })
        .collect()
}

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

    fn diff_a() -> Vec<FileDiff> {
        parse_unified(
            "diff --git a/src/x.rs b/src/x.rs\n\
             --- a/src/x.rs\n\
             +++ b/src/x.rs\n\
             @@ -1,3 +1,3 @@\n\
             -old line\n\
             +new line\n\
              context\n",
        )
    }

    fn diff_b_textual() -> Vec<FileDiff> {
        // Same shape as diff_a but the added line text differs.
        parse_unified(
            "diff --git a/src/x.rs b/src/x.rs\n\
             --- a/src/x.rs\n\
             +++ b/src/x.rs\n\
             @@ -1,3 +1,3 @@\n\
             -old line\n\
             +DIFFERENT new line\n\
              context\n",
        )
    }

    fn tmp_path(name: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!(
            "shuire-storage-{}-{}-{}",
            std::process::id(),
            name,
            // Unique per-test suffix in case tests run repeatedly against the
            // same tmp dir (e.g. CI restarts).
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        p
    }

    fn sample_comment() -> Comment {
        Comment {
            file: "src/main.rs".to_string(),
            side: Side::New,
            lineno: 42,
            lineno_end: Some(44),
            body: "Looks wrong\n日本語 body".to_string(),
            replies: vec!["ack".to_string(), "lgtm 🎉".to_string()],
        }
    }

    #[test]
    fn save_and_load_round_trip_preserves_all_fields() {
        let path = tmp_path("roundtrip.json");
        let comments = vec![sample_comment()];
        save_comments_at(&path, &comments);
        let loaded = load_comments_at(&path);
        let _ = std::fs::remove_file(&path);

        assert_eq!(loaded.len(), 1);
        let a = &loaded[0];
        let b = &comments[0];
        assert_eq!(a.file, b.file);
        assert_eq!(a.side, b.side);
        assert_eq!(a.lineno, b.lineno);
        assert_eq!(a.lineno_end, b.lineno_end);
        assert_eq!(a.body, b.body);
        assert_eq!(a.replies, b.replies);
    }

    #[test]
    fn save_empty_clears_file_on_disk() {
        let path = tmp_path("empty.json");
        // Pre-populate the file so we can observe that an empty save removes it.
        save_comments_at(&path, &[sample_comment()]);
        assert!(path.exists(), "precondition: file written");

        save_comments_at(&path, &[]);
        assert!(!path.exists(), "empty save should remove the file");
    }

    #[test]
    fn load_missing_file_returns_empty_vec() {
        let path = tmp_path("never-exists.json");
        let loaded = load_comments_at(&path);
        assert!(loaded.is_empty());
    }

    #[test]
    fn load_malformed_json_returns_empty_without_panic() {
        let path = tmp_path("malformed.json");
        std::fs::write(&path, "not json").unwrap();
        let loaded = load_comments_at(&path);
        let _ = std::fs::remove_file(&path);
        assert!(loaded.is_empty());
    }

    #[test]
    fn diff_fingerprint_is_stable_for_identical_diff() {
        let a = diff_a();
        let b = diff_a();
        assert_eq!(diff_fingerprint(&a), diff_fingerprint(&b));
    }

    #[test]
    fn diff_fingerprint_changes_when_diff_text_changes() {
        let a = diff_fingerprint(&diff_a());
        let b = diff_fingerprint(&diff_b_textual());
        assert_ne!(a, b, "different diff text must yield different fingerprint");
    }

    #[test]
    fn clear_comments_removes_all_fingerprints_for_range() {
        let dir = tmp_path("clear-fingerprints");
        std::fs::create_dir_all(&dir).unwrap();

        let range = DiffRange::Stdin;
        let prefix = format!("{}-", range_key(&range));
        let path_a = dir.join(format!("{prefix}fp_one.json"));
        let path_b = dir.join(format!("{prefix}fp_two.json"));
        // Also drop an unrelated file so we can prove we don't over-delete.
        let unrelated = dir.join("deadbeef-other.json");
        save_comments_at(&path_a, &[sample_comment()]);
        save_comments_at(&path_b, &[sample_comment()]);
        save_comments_at(&unrelated, &[sample_comment()]);

        clear_comments_in(&dir, &range);

        assert!(!path_a.exists(), "fingerprint variant a should be removed");
        assert!(!path_b.exists(), "fingerprint variant b should be removed");
        assert!(unrelated.exists(), "unrelated range must be untouched");

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn save_creates_parent_directories() {
        let mut path = std::env::temp_dir();
        path.push(format!(
            "shuire-storage-mkdir-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        path.push("nested");
        path.push("dir");
        path.push("comments.json");

        save_comments_at(&path, &[sample_comment()]);
        assert!(path.exists(), "parent dirs should have been created");
        // Cleanup (best-effort).
        let _ = std::fs::remove_file(&path);
        if let Some(p) = path.parent() {
            let _ = std::fs::remove_dir_all(p);
        }
    }
}