hewdiff 0.5.0

High-performance review-first terminal diff viewer with PR-style comments
//! PR-style review comments, loaded from a sidecar JSON file and edited in
//! place. The store is the single in-memory source of truth: the TUI (and,
//! the in-app composer) mutate it through the methods on
//! [`CommentStore`]; on exit it is diffed against the immutable base into an
//! action log (see [`super::diff`]).

use crate::diff::model::Side;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::SystemTime;
use uuid::Uuid;

/// Mint a fresh, unique id for a thread/comment that arrives without one.
/// Ids that ARE present in the sidecar JSON are kept verbatim (the `id` fields
/// are plain `String`s, not `Uuid`s): the action log emitted on exit must
/// reference the same ids the base sidecar used, so a PR-sourced sidecar
/// carrying foreign GitHub ids (numeric REST ids, GraphQL node ids like
/// `PRRT_kwD…`) stays replayable instead of being rewritten to random UUIDs.
fn new_id() -> String {
    Uuid::new_v4().to_string()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineRange {
    pub start: u32,
    pub end: u32,
}

impl LineRange {
    pub fn contains(&self, line: u32) -> bool {
        line >= self.start && line <= self.end
    }
}

/// A single message in a thread (root or reply).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
    #[serde(default = "new_id")]
    pub id: String,
    #[serde(default)]
    pub author: Option<String>,
    pub body: String,
    // Sidecar JSON may omit this; default to "now".
    #[serde(with = "ts", default = "SystemTime::now")]
    pub created_at: SystemTime,
}

/// A review thread anchored to a line range. `comments[0]` is the root.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
    #[serde(default = "new_id")]
    pub id: String,
    pub file: PathBuf,
    pub side: Side,
    pub range: LineRange,
    #[serde(default)]
    pub resolved: bool,
    pub comments: Vec<Comment>,
}

/// Owns every thread loaded from the sidecar.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CommentStore {
    pub threads: Vec<Thread>,
}

impl CommentStore {
    /// Start a new thread anchored to `(file, side, range)` with a root
    /// comment, returning its thread id. This is the single write path used by
    /// the TUI composer.
    pub fn add_thread(
        &mut self,
        file: PathBuf,
        side: Side,
        range: LineRange,
        author: Option<String>,
        body: String,
    ) -> String {
        let id = new_id();
        self.threads.push(Thread {
            id: id.clone(),
            file,
            side,
            range,
            resolved: false,
            comments: vec![Comment {
                id: new_id(),
                author,
                body,
                created_at: SystemTime::now(),
            }],
        });
        id
    }

    /// Append a reply to the thread with `thread_id`. Returns `false` when no
    /// such thread exists.
    pub fn reply(&mut self, thread_id: &str, author: Option<String>, body: String) -> bool {
        match self.threads.iter_mut().find(|t| t.id == thread_id) {
            Some(t) => {
                t.comments.push(Comment {
                    id: new_id(),
                    author,
                    body,
                    created_at: SystemTime::now(),
                });
                true
            }
            None => false,
        }
    }

    /// Remove the single comment `comment_id` from thread `thread_id`. If that
    /// leaves the thread empty, the thread is dropped too. Returns `true` when a
    /// comment was removed. This is the only delete path used by the TUI — the
    /// unit of deletion is a comment, never a whole thread.
    pub fn remove_comment(&mut self, thread_id: &str, comment_id: &str) -> bool {
        let Some(t) = self.threads.iter_mut().find(|t| t.id == thread_id) else {
            return false;
        };
        let before = t.comments.len();
        t.comments.retain(|c| c.id != comment_id);
        if t.comments.len() == before {
            return false;
        }
        if t.comments.is_empty() {
            self.threads.retain(|t| t.id != thread_id);
        }
        true
    }

    /// Flip the resolved flag on the thread with `id`, returning the new state
    /// (or `None` when no such thread exists).
    pub fn toggle_resolved(&mut self, id: &str) -> Option<bool> {
        let t = self.threads.iter_mut().find(|t| t.id == id)?;
        t.resolved = !t.resolved;
        Some(t.resolved)
    }
}

/// Serialize `SystemTime` as a unix-millis integer.
mod ts {
    use serde::{Deserialize, Deserializer, Serializer};
    use std::time::{Duration, SystemTime, UNIX_EPOCH};

    pub fn serialize<S: Serializer>(t: &SystemTime, s: S) -> Result<S::Ok, S::Error> {
        let ms = t.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
        s.serialize_u64(ms)
    }
    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SystemTime, D::Error> {
        let ms = u64::deserialize(d)?;
        Ok(UNIX_EPOCH + Duration::from_millis(ms))
    }
}

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

    fn range(start: u32, end: u32) -> LineRange {
        LineRange { start, end }
    }

    #[test]
    fn add_thread_then_reply() {
        let mut store = CommentStore::default();
        let id = store.add_thread(
            "src/main.rs".into(),
            Side::New,
            range(10, 12),
            Some("agent".into()),
            "looks off".into(),
        );
        assert_eq!(store.threads.len(), 1);
        assert_eq!(store.threads[0].comments.len(), 1);
        assert!(!store.threads[0].resolved);

        assert!(store.reply(&id, Some("you".into()), "good catch".into()));
        assert_eq!(store.threads[0].comments.len(), 2);
        assert_eq!(store.threads[0].comments[1].body, "good catch");

        // Replying to an unknown thread is a no-op.
        assert!(!store.reply("nonexistent", None, "x".into()));
        assert_eq!(store.threads[0].comments.len(), 2);
    }

    #[test]
    fn toggle_resolved_flips_and_reports_missing() {
        let mut store = CommentStore::default();
        let id = store.add_thread("f".into(), Side::Old, range(1, 1), None, "hi".into());
        assert_eq!(store.toggle_resolved(&id), Some(true));
        assert!(store.threads[0].resolved);
        assert_eq!(store.toggle_resolved(&id), Some(false));
        assert!(!store.threads[0].resolved);
        // Unknown ids report failure without panicking.
        assert_eq!(store.toggle_resolved("nonexistent"), None);
    }

    #[test]
    fn remove_comment_drops_reply_then_thread() {
        let mut store = CommentStore::default();
        let id = store.add_thread("f".into(), Side::New, range(2, 2), None, "root".into());
        store.reply(&id, Some("you".into()), "reply".into());
        let root_id = store.threads[0].comments[0].id.clone();
        let reply_id = store.threads[0].comments[1].id.clone();

        // Removing the reply keeps the thread (root remains).
        assert!(store.remove_comment(&id, &reply_id));
        assert_eq!(store.threads[0].comments.len(), 1);
        // Removing an unknown comment is a no-op.
        assert!(!store.remove_comment(&id, &reply_id));
        // Removing the last comment drops the now-empty thread.
        assert!(store.remove_comment(&id, &root_id));
        assert!(store.threads.is_empty());
    }
}