1use std::collections::BTreeMap;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use serde::{Deserialize, Serialize};
8
9use crate::model::DiffModel;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum CommentStatus {
14 Open,
15 Replied,
16 Resolved,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Reply {
21 pub author: String,
22 pub body: String,
23 pub at: u64,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub remote_id: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Anchor {
34 pub file: String,
35 #[serde(default)]
36 pub line: Option<u32>,
37 #[serde(default)]
38 pub line_end: Option<u32>,
39 #[serde(default)]
40 pub on_old_side: bool,
41 #[serde(default)]
44 pub line_text: Option<String>,
45}
46
47impl Anchor {
48 pub fn is_outdated(&self, model: &DiffModel) -> bool {
53 match self.line_end.or(self.line) {
54 Some(line) => match model.find_line(&self.file, line, self.on_old_side) {
55 Some(found) => self
56 .line_text
57 .as_deref()
58 .is_some_and(|snap| snap != found.text),
59 None => true,
60 },
61 None => !model.files.iter().any(|f| f.path == self.file),
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct Comment {
68 pub id: String,
69 pub author: String,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub remote_id: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub thread_id: Option<String>,
77 pub anchor: Anchor,
78 pub body: String,
79 pub status: CommentStatus,
80 #[serde(default)]
81 pub replies: Vec<Reply>,
82 pub at: u64,
83}
84
85#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Session {
87 #[serde(default)]
88 pub comments: Vec<Comment>,
89 #[serde(default)]
92 pub viewed: BTreeMap<String, String>,
93}
94
95pub fn now_unix() -> u64 {
96 SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .map_or(0, |d| d.as_secs())
99}
100
101impl Session {
102 pub fn add_comment(&mut self, anchor: Anchor, author: &str, body: &str) -> &Comment {
103 self.comments.push(Comment {
104 remote_id: None,
105 thread_id: None,
106 id: uuid::Uuid::new_v4().to_string(),
107 author: author.to_owned(),
108 anchor,
109 body: body.to_owned(),
110 status: CommentStatus::Open,
111 replies: Vec::new(),
112 at: now_unix(),
113 });
114 #[allow(clippy::expect_used)]
116 self.comments.last().expect("just pushed")
117 }
118
119 fn comment_mut(&mut self, comment_id: &str) -> Option<&mut Comment> {
120 self.comments.iter_mut().find(|c| c.id == comment_id)
121 }
122
123 pub fn delete_comment(&mut self, id: &str) -> bool {
125 let before = self.comments.len();
126 self.comments.retain(|c| c.id != id);
127 self.comments.len() != before
128 }
129
130 pub fn reply(&mut self, comment_id: &str, author: &str, body: &str) -> bool {
131 let Some(comment) = self.comment_mut(comment_id) else {
132 return false;
133 };
134 comment.replies.push(Reply {
135 remote_id: None,
136 author: author.to_owned(),
137 body: body.to_owned(),
138 at: now_unix(),
139 });
140 self.mark_replied(comment_id)
141 }
142
143 pub fn comment(&self, id: &str) -> Option<&Comment> {
144 self.comments.iter().find(|comment| comment.id == id)
145 }
146
147 pub fn mark_replied(&mut self, comment_id: &str) -> bool {
151 let Some(comment) = self.comment_mut(comment_id) else {
152 return false;
153 };
154 if comment.status == CommentStatus::Open {
155 comment.status = CommentStatus::Replied;
156 }
157 true
158 }
159
160 pub fn resolve(&mut self, comment_id: &str) -> bool {
161 let Some(comment) = self.comment_mut(comment_id) else {
162 return false;
163 };
164 comment.status = CommentStatus::Resolved;
165 true
166 }
167
168 pub fn edit_comment(&mut self, comment_id: &str, body: &str) -> bool {
172 let Some(comment) = self.comment_mut(comment_id) else {
173 return false;
174 };
175 body.clone_into(&mut comment.body);
176 true
177 }
178
179 pub fn mark_viewed(&mut self, path: &str, hash: &str) {
180 self.viewed.insert(path.to_owned(), hash.to_owned());
181 }
182
183 pub fn unmark_viewed(&mut self, path: &str) {
184 self.viewed.remove(path);
185 }
186
187 pub fn clear_viewed(&mut self) {
189 self.viewed.clear();
190 }
191
192 pub fn is_viewed(&self, path: &str, current_hash: &str) -> bool {
195 self.viewed.get(path).is_some_and(|h| h == current_hash)
196 }
197
198 pub fn reconcile(&mut self, model: &DiffModel) {
202 let live: BTreeMap<&str, String> = model
203 .files
204 .iter()
205 .map(|f| (f.path.as_str(), f.content_hash()))
206 .collect();
207 self.viewed
208 .retain(|path, hash| live.get(path.as_str()).is_some_and(|h| h == hash));
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
215 use crate::test_support::{anchor, file_diff};
216
217 use super::*;
218
219 fn model(files: Vec<FileDiff>) -> DiffModel {
220 DiffModel { files }
221 }
222
223 #[test]
224 fn comment_lifecycle_open_replied_resolved() {
225 let mut s = Session::default();
226 let id = s
227 .add_comment(anchor("a.txt", Some(3)), "reviewer", "why?")
228 .id
229 .clone();
230 assert_eq!(s.comments[0].status, CommentStatus::Open);
231 assert!(s.reply(&id, "agent", "because"));
232 assert_eq!(s.comments[0].status, CommentStatus::Replied);
233 assert!(s.resolve(&id));
234 assert_eq!(s.comments[0].status, CommentStatus::Resolved);
235 assert!(
236 s.comments
237 .iter()
238 .all(|c| c.status == CommentStatus::Resolved)
239 );
240 }
241
242 #[test]
243 fn reply_to_missing_comment_returns_false() {
244 let mut s = Session::default();
245 assert!(!s.reply("nope", "agent", "hi"));
246 }
247
248 #[test]
249 fn resolve_missing_comment_returns_false() {
250 let mut s = Session::default();
251 assert!(!s.resolve("nope"));
252 }
253
254 #[test]
255 fn edit_comment_replaces_body_and_keeps_status() {
256 let mut s = Session::default();
257 let id = s
258 .add_comment(anchor("a.txt", Some(3)), "reviewer", "old body")
259 .id
260 .clone();
261 assert!(s.reply(&id, "agent", "ack"));
262 assert!(s.edit_comment(&id, "new body"));
263 let c = s.comments.iter().find(|c| c.id == id).expect("comment");
264 assert_eq!(c.body, "new body");
265 assert_eq!(c.status, CommentStatus::Replied, "status is untouched");
266 assert_eq!(c.replies.len(), 1, "replies are untouched");
267 assert!(!s.edit_comment("nope", "x"));
268 }
269
270 #[test]
271 fn unresolved_comments_filters_resolved_only() {
272 let mut s = Session::default();
273 let keep = s
274 .add_comment(anchor("a.txt", Some(3)), "reviewer", "open")
275 .id
276 .clone();
277 let done = s
278 .add_comment(anchor("a.txt", Some(3)), "reviewer", "done")
279 .id
280 .clone();
281 assert!(s.resolve(&done));
282 let unresolved: Vec<_> = s
283 .comments
284 .iter()
285 .filter(|c| c.status != CommentStatus::Resolved)
286 .map(|c| c.id.clone())
287 .collect();
288 assert_eq!(unresolved, vec![keep]);
289 }
290
291 #[test]
292 fn session_serializes_round_trip_with_range_anchor() {
293 let mut s = Session::default();
294 let mut range = anchor("a.txt", Some(3));
295 range.line_end = Some(7);
296 s.add_comment(range, "reviewer", "this whole block");
297 s.mark_viewed("b.txt", "hash-b");
298 let json = serde_json::to_string(&s).expect("serialize");
299 let back: Session = serde_json::from_str(&json).expect("deserialize");
300 assert_eq!(s, back);
301 assert_eq!(back.comments[0].anchor.line_end, Some(7));
302 }
303
304 #[test]
305 fn is_viewed_true_for_same_hash_false_after_change() {
306 let mut s = Session::default();
307 s.mark_viewed("a.txt", "hash-1");
308 assert!(s.is_viewed("a.txt", "hash-1"));
309 assert!(!s.is_viewed("a.txt", "hash-2"));
310 assert!(!s.is_viewed("other.txt", "hash-1"));
311 }
312
313 #[test]
314 fn unmark_viewed_removes_entry() {
315 let mut s = Session::default();
316 s.mark_viewed("a.txt", "hash-1");
317 s.unmark_viewed("a.txt");
318 assert!(!s.is_viewed("a.txt", "hash-1"));
319 }
320
321 #[test]
322 fn clear_viewed_drops_every_mark() {
323 let mut s = Session::default();
324 s.mark_viewed("a.txt", "hash-1");
325 s.mark_viewed("b.txt", "hash-2");
326 s.clear_viewed();
327 assert!(!s.is_viewed("a.txt", "hash-1"));
328 assert!(!s.is_viewed("b.txt", "hash-2"));
329 }
330
331 fn hunked_model() -> DiffModel {
333 DiffModel {
334 files: vec![FileDiff {
335 path: "src/auth.py".into(),
336 old_path: None,
337 status: FileStatus::Modified,
338 binary: false,
339 old_text: None,
340 new_text: None,
341 hunks: vec![Hunk {
342 id: HunkId("h1".into()),
343 old_start: 1,
344 old_lines: 3,
345 new_start: 1,
346 new_lines: 3,
347 context: String::new(),
348 lines: vec![
349 DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
350 DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
351 DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
352 DiffLine::new(LineKind::Context, Some(3), Some(3), "three".into()),
353 ],
354 }],
355 hashes: crate::model::HashCache::default(),
356 }],
357 }
358 }
359
360 #[test]
361 fn anchor_with_matching_line_text_is_current() {
362 let mut a = anchor("src/auth.py", Some(2));
363 a.line_text = Some("TWO".to_owned());
364 assert!(!a.is_outdated(&hunked_model()));
365 a.line_text = None;
367 assert!(!a.is_outdated(&hunked_model()));
368 }
369
370 #[test]
371 fn anchor_with_drifted_line_text_is_outdated() {
372 let mut a = anchor("src/auth.py", Some(2));
373 a.line_text = Some("old text".to_owned());
374 assert!(a.is_outdated(&hunked_model()));
375 }
376
377 #[test]
378 fn anchor_to_a_departed_line_is_outdated() {
379 let a = anchor("src/auth.py", Some(99));
380 assert!(a.is_outdated(&hunked_model()));
381 }
382
383 #[test]
384 fn old_side_anchor_checks_the_old_line() {
385 let mut a = anchor("src/auth.py", Some(2));
386 a.on_old_side = true;
387 a.line_text = Some("two".to_owned());
388 assert!(!a.is_outdated(&hunked_model()));
389 a.line_text = Some("TWO".to_owned());
390 assert!(a.is_outdated(&hunked_model()), "old side carries 'two'");
391 }
392
393 #[test]
394 fn range_anchor_judges_drift_on_its_end_line() {
395 let mut a = anchor("src/auth.py", Some(1));
396 a.line_end = Some(3);
397 a.line_text = Some("three".to_owned());
398 assert!(!a.is_outdated(&hunked_model()), "end line still matches");
399 a.line_text = Some("changed".to_owned());
400 assert!(a.is_outdated(&hunked_model()), "end line drifted");
401 }
402
403 #[test]
404 fn file_level_anchor_is_outdated_only_when_the_file_departs() {
405 assert!(!anchor("src/auth.py", None).is_outdated(&hunked_model()));
406 assert!(anchor("gone.py", None).is_outdated(&hunked_model()));
407 }
408
409 #[test]
410 fn reconcile_drops_departed_and_changed_keeps_matching() {
411 let kept = file_diff("kept.txt", "stable content\n");
412 let changed = file_diff("changed.txt", "rewritten content\n");
413
414 let mut s = Session::default();
415 s.mark_viewed("kept.txt", &kept.content_hash());
416 s.mark_viewed("changed.txt", "hash-of-old-content");
417 s.mark_viewed("departed.txt", "whatever");
418 s.add_comment(
419 anchor("departed.txt", Some(3)),
420 "reviewer",
421 "still relevant",
422 );
423
424 s.reconcile(&model(vec![kept, changed]));
425
426 assert!(s.viewed.contains_key("kept.txt"));
427 assert!(!s.viewed.contains_key("changed.txt"));
428 assert!(!s.viewed.contains_key("departed.txt"));
429 assert_eq!(s.comments.len(), 1);
431 }
432}