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