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