use crate::diff::model::Side;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::SystemTime;
use uuid::Uuid;
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
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
#[serde(default = "new_id")]
pub id: String,
#[serde(default)]
pub author: Option<String>,
pub body: String,
#[serde(with = "ts", default = "SystemTime::now")]
pub created_at: SystemTime,
}
#[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>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CommentStore {
pub threads: Vec<Thread>,
}
impl CommentStore {
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
}
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,
}
}
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
}
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)
}
}
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");
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);
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();
assert!(store.remove_comment(&id, &reply_id));
assert_eq!(store.threads[0].comments.len(), 1);
assert!(!store.remove_comment(&id, &reply_id));
assert!(store.remove_comment(&id, &root_id));
assert!(store.threads.is_empty());
}
}