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