1use std::collections::{BTreeMap, BTreeSet};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::{Deserialize, Serialize};
7
8use crate::model::DiffModel;
9use crate::walkthrough::Walkthrough;
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 span(&self) -> Option<(u32, u32)> {
50 self.line.map(|line| (line, self.line_end.unwrap_or(line)))
51 }
52
53 pub fn is_outdated(&self, model: &DiffModel) -> bool {
56 match self.line_end.or(self.line) {
57 Some(line) => match model.find_line(&self.file, line, self.on_old_side) {
58 Some(found) => self
59 .line_text
60 .as_deref()
61 .is_some_and(|snap| snap != found.text),
62 None => true,
63 },
64 None => !model.files.iter().any(|f| f.path == self.file),
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Comment {
71 pub id: String,
72 pub author: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub remote_id: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub thread_id: Option<String>,
79 pub anchor: Anchor,
80 #[serde(default)]
82 pub title: Option<String>,
83 #[serde(default)]
86 pub anchor_ref: Option<String>,
87 pub body: String,
88 pub status: CommentStatus,
89 #[serde(default)]
90 pub replies: Vec<Reply>,
91 pub at: u64,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
95pub struct Session {
96 #[serde(default)]
97 pub comments: Vec<Comment>,
98 #[serde(default)]
101 pub viewed: BTreeMap<String, String>,
102 #[serde(default)]
104 pub walkthrough: Option<Walkthrough>,
105 #[serde(default)]
108 pub seen_stops: BTreeSet<String>,
109}
110
111pub fn now_unix() -> u64 {
112 SystemTime::now()
113 .duration_since(UNIX_EPOCH)
114 .map_or(0, |d| d.as_secs())
115}
116
117pub fn now_unix_millis() -> u64 {
120 SystemTime::now()
121 .duration_since(UNIX_EPOCH)
122 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
123}
124
125impl Session {
126 pub fn add_comment(&mut self, anchor: Anchor, author: &str, body: &str) -> &Comment {
127 self.comments.push(Comment {
128 remote_id: None,
129 thread_id: None,
130 id: uuid::Uuid::new_v4().to_string(),
131 author: author.to_owned(),
132 anchor,
133 title: None,
134 anchor_ref: None,
135 body: body.to_owned(),
136 status: CommentStatus::Open,
137 replies: Vec::new(),
138 at: now_unix(),
139 });
140 #[allow(clippy::expect_used)]
141 self.comments.last().expect("just pushed")
142 }
143
144 fn comment_mut(&mut self, comment_id: &str) -> Option<&mut Comment> {
145 self.comments.iter_mut().find(|c| c.id == comment_id)
146 }
147
148 pub fn set_walkthrough(&mut self, walkthrough: Walkthrough) {
149 self.walkthrough = Some(walkthrough);
150 self.prune_seen_stops();
151 }
152
153 pub fn delete_stop(&mut self, index: usize) -> bool {
156 let Some(walkthrough) = self.walkthrough.as_ref() else {
157 return false;
158 };
159 let Some(stop_id) = walkthrough.stops.get(index).cloned() else {
160 return false;
161 };
162 let mut remove: BTreeSet<String> = walkthrough
163 .notes_by_stop(&self.comments)
164 .get(index)
165 .cloned()
166 .unwrap_or_default()
167 .into_iter()
168 .collect();
169 remove.insert(stop_id);
170 let Some(walkthrough) = self.walkthrough.as_mut() else {
171 return false;
172 };
173 walkthrough.stops.remove(index);
174 self.comments.retain(|c| !remove.contains(&c.id));
175 self.prune_seen_stops();
176 true
177 }
178
179 fn prune_seen_stops(&mut self) {
180 let stops: BTreeSet<&str> = self
181 .walkthrough
182 .iter()
183 .flat_map(|w| w.stops.iter().map(String::as_str))
184 .collect();
185 self.seen_stops.retain(|id| stops.contains(id.as_str()));
186 }
187
188 pub fn mark_stop_seen(&mut self, id: &str) {
189 self.seen_stops.insert(id.to_owned());
190 }
191
192 pub fn unmark_stop_seen(&mut self, id: &str) {
193 self.seen_stops.remove(id);
194 }
195
196 pub fn is_stop_seen(&self, id: &str) -> bool {
197 self.seen_stops.contains(id)
198 }
199
200 pub fn delete_comment(&mut self, id: &str) -> bool {
201 let before = self.comments.len();
202 self.comments.retain(|c| c.id != id);
203 self.comments.len() != before
204 }
205
206 pub fn reply(&mut self, comment_id: &str, author: &str, body: &str) -> bool {
207 let Some(comment) = self.comment_mut(comment_id) else {
208 return false;
209 };
210 comment.replies.push(Reply {
211 remote_id: None,
212 author: author.to_owned(),
213 body: body.to_owned(),
214 at: now_unix(),
215 });
216 self.mark_replied(comment_id)
217 }
218
219 pub fn comment(&self, id: &str) -> Option<&Comment> {
220 self.comments.iter().find(|comment| comment.id == id)
221 }
222
223 pub fn mark_replied(&mut self, comment_id: &str) -> bool {
225 let Some(comment) = self.comment_mut(comment_id) else {
226 return false;
227 };
228 if comment.status == CommentStatus::Open {
229 comment.status = CommentStatus::Replied;
230 }
231 true
232 }
233
234 pub fn resolve(&mut self, comment_id: &str) -> bool {
235 let Some(comment) = self.comment_mut(comment_id) else {
236 return false;
237 };
238 comment.status = CommentStatus::Resolved;
239 true
240 }
241
242 pub fn edit_comment(&mut self, comment_id: &str, body: &str) -> bool {
245 let Some(comment) = self.comment_mut(comment_id) else {
246 return false;
247 };
248 body.clone_into(&mut comment.body);
249 true
250 }
251
252 pub fn mark_viewed(&mut self, path: &str, hash: &str) {
253 self.viewed.insert(path.to_owned(), hash.to_owned());
254 }
255
256 pub fn unmark_viewed(&mut self, path: &str) {
257 self.viewed.remove(path);
258 }
259
260 pub fn clear_viewed(&mut self) {
261 self.viewed.clear();
262 }
263
264 pub fn is_viewed(&self, path: &str, current_hash: &str) -> bool {
266 self.viewed.get(path).is_some_and(|h| h == current_hash)
267 }
268
269 pub fn reconcile(&mut self, model: &DiffModel) {
272 let live: BTreeMap<&str, String> = model
273 .files
274 .iter()
275 .map(|f| (f.path.as_str(), f.content_hash()))
276 .collect();
277 self.viewed
278 .retain(|path, hash| live.get(path.as_str()).is_some_and(|h| h == hash));
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
285 use crate::test_support::{anchor, file_diff};
286
287 use super::*;
288
289 fn model(files: Vec<FileDiff>) -> DiffModel {
290 DiffModel { files }
291 }
292
293 #[test]
294 fn comment_lifecycle_open_replied_resolved() {
295 let mut s = Session::default();
296 let id = s
297 .add_comment(anchor("a.txt", Some(3)), "reviewer", "why?")
298 .id
299 .clone();
300 assert_eq!(s.comments[0].status, CommentStatus::Open);
301 assert!(s.reply(&id, "agent", "because"));
302 assert_eq!(s.comments[0].status, CommentStatus::Replied);
303 assert!(s.resolve(&id));
304 assert_eq!(s.comments[0].status, CommentStatus::Resolved);
305 assert!(
306 s.comments
307 .iter()
308 .all(|c| c.status == CommentStatus::Resolved)
309 );
310 }
311
312 #[test]
313 fn reply_to_missing_comment_returns_false() {
314 let mut s = Session::default();
315 assert!(!s.reply("nope", "agent", "hi"));
316 }
317
318 #[test]
319 fn resolve_missing_comment_returns_false() {
320 let mut s = Session::default();
321 assert!(!s.resolve("nope"));
322 }
323
324 #[test]
325 fn edit_comment_replaces_body_and_keeps_status() {
326 let mut s = Session::default();
327 let id = s
328 .add_comment(anchor("a.txt", Some(3)), "reviewer", "old body")
329 .id
330 .clone();
331 assert!(s.reply(&id, "agent", "ack"));
332 assert!(s.edit_comment(&id, "new body"));
333 let c = s.comments.iter().find(|c| c.id == id).expect("comment");
334 assert_eq!(c.body, "new body");
335 assert_eq!(c.status, CommentStatus::Replied, "status is untouched");
336 assert_eq!(c.replies.len(), 1, "replies are untouched");
337 assert!(!s.edit_comment("nope", "x"));
338 }
339
340 #[test]
341 fn unresolved_comments_filters_resolved_only() {
342 let mut s = Session::default();
343 let keep = s
344 .add_comment(anchor("a.txt", Some(3)), "reviewer", "open")
345 .id
346 .clone();
347 let done = s
348 .add_comment(anchor("a.txt", Some(3)), "reviewer", "done")
349 .id
350 .clone();
351 assert!(s.resolve(&done));
352 let unresolved: Vec<_> = s
353 .comments
354 .iter()
355 .filter(|c| c.status != CommentStatus::Resolved)
356 .map(|c| c.id.clone())
357 .collect();
358 assert_eq!(unresolved, vec![keep]);
359 }
360
361 #[test]
362 fn session_serializes_round_trip_with_range_anchor() {
363 let mut s = Session::default();
364 let mut range = anchor("a.txt", Some(3));
365 range.line_end = Some(7);
366 s.add_comment(range, "reviewer", "this whole block");
367 s.mark_viewed("b.txt", "hash-b");
368 let json = serde_json::to_string(&s).expect("serialize");
369 let back: Session = serde_json::from_str(&json).expect("deserialize");
370 assert_eq!(s, back);
371 assert_eq!(back.comments[0].anchor.line_end, Some(7));
372 }
373
374 #[test]
375 fn a_session_with_the_old_boards_key_and_no_walkthrough_key_loads_with_none() {
376 let json = r#"{"comments":[],"viewed":{},"boards":[]}"#;
377 let s: Session = serde_json::from_str(json).expect("deserialize");
378 assert!(s.walkthrough.is_none());
379 }
380
381 #[test]
382 fn a_walkthrough_session_deserializes_its_walkthrough_and_ignores_a_stale_comments_field() {
383 let json = r#"{"comments":[],"viewed":{},"walkthrough":{"id":"w1","title":"tour","author":"agent","at":1,"stops":["stop-0"],"comments":["stop-0"]}}"#;
384 let s: Session = serde_json::from_str(json).expect("deserialize");
385 assert_eq!(s.walkthrough.expect("walkthrough").id, "w1");
386 }
387
388 #[test]
389 fn a_session_with_no_seen_stops_key_loads_empty() {
390 let json = r#"{"comments":[],"viewed":{}}"#;
391 let s: Session = serde_json::from_str(json).expect("deserialize");
392 assert!(s.seen_stops.is_empty());
393 }
394
395 #[test]
396 fn mark_and_unmark_stop_seen_round_trip() {
397 let mut s = Session::default();
398 assert!(!s.is_stop_seen("stop-0"));
399 s.mark_stop_seen("stop-0");
400 assert!(s.is_stop_seen("stop-0"));
401 s.unmark_stop_seen("stop-0");
402 assert!(!s.is_stop_seen("stop-0"));
403 }
404
405 fn walkthrough(id: &str, stops: &[&str]) -> Walkthrough {
406 Walkthrough {
407 id: id.to_owned(),
408 title: "tour".to_owned(),
409 author: "agent".to_owned(),
410 at: 1,
411 stops: stops.iter().map(|s| (*s).to_owned()).collect(),
412 skipped: None,
413 summary: None,
414 rev: None,
415 about: crate::source::ReviewSource::WorkingTree,
416 }
417 }
418
419 #[test]
420 fn set_walkthrough_prunes_seen_marks_for_dropped_stops() {
421 let mut s = Session::default();
422 s.mark_stop_seen("stop-0");
423 s.mark_stop_seen("stop-1");
424 s.set_walkthrough(walkthrough("w1", &["stop-1"]));
425 assert!(!s.is_stop_seen("stop-0"), "stop-0 no longer exists");
426 assert!(s.is_stop_seen("stop-1"), "stop-1 survives the revision");
427 }
428
429 #[test]
430 fn set_walkthrough_replaces_whatever_was_there() {
431 let mut s = Session::default();
432 s.set_walkthrough(walkthrough("w1", &["a"]));
433 s.set_walkthrough(Walkthrough {
434 title: "revised".to_owned(),
435 at: 2,
436 ..walkthrough("w1", &["a"])
437 });
438 assert_eq!(s.walkthrough.expect("walkthrough").title, "revised");
439 }
440
441 fn agent_comment(id: &str, file: &str, line: u32, title: Option<&str>) -> Comment {
442 Comment {
443 id: id.to_owned(),
444 author: "agent".to_owned(),
445 remote_id: None,
446 thread_id: None,
447 anchor: anchor(file, Some(line)),
448 title: title.map(str::to_owned),
449 anchor_ref: Some(format!("{file}:{line}")),
450 body: "why".to_owned(),
451 status: CommentStatus::Open,
452 replies: Vec::new(),
453 at: 1,
454 }
455 }
456
457 fn human_comment(id: &str, file: &str, line: u32) -> Comment {
458 Comment {
459 author: "reviewer".to_owned(),
460 title: None,
461 anchor_ref: None,
462 ..agent_comment(id, file, line, None)
463 }
464 }
465
466 #[test]
467 fn delete_stop_removes_its_primary_and_notes_but_keeps_a_human_comment_there() {
468 let mut s = Session::default();
469 s.comments
470 .push(agent_comment("stop-0", "a.txt", 1, Some("first")));
471 s.comments.push(agent_comment("note-0", "a.txt", 1, None));
472 s.comments.push(human_comment("human-0", "a.txt", 1));
473 s.comments
474 .push(agent_comment("stop-1", "b.txt", 1, Some("second")));
475 s.set_walkthrough(walkthrough("w1", &["stop-0", "stop-1"]));
476
477 assert!(s.delete_stop(0));
478
479 let walkthrough = s.walkthrough.as_ref().expect("the walkthrough is kept");
480 assert_eq!(walkthrough.stops, ["stop-1".to_owned()]);
481 let ids: Vec<&str> = s.comments.iter().map(|c| c.id.as_str()).collect();
482 assert_eq!(
483 ids,
484 ["human-0", "stop-1"],
485 "the human comment in stop-0's region survives"
486 );
487 }
488
489 #[test]
490 fn delete_stop_is_false_out_of_range_or_without_a_walkthrough() {
491 let mut s = Session::default();
492 assert!(!s.delete_stop(0), "no walkthrough at all");
493
494 s.comments
495 .push(agent_comment("stop-0", "a.txt", 1, Some("first")));
496 s.set_walkthrough(walkthrough("w1", &["stop-0"]));
497 assert!(!s.delete_stop(5), "past the end");
498 }
499
500 #[test]
501 fn is_viewed_true_for_same_hash_false_after_change() {
502 let mut s = Session::default();
503 s.mark_viewed("a.txt", "hash-1");
504 assert!(s.is_viewed("a.txt", "hash-1"));
505 assert!(!s.is_viewed("a.txt", "hash-2"));
506 assert!(!s.is_viewed("other.txt", "hash-1"));
507 }
508
509 #[test]
510 fn unmark_viewed_removes_entry() {
511 let mut s = Session::default();
512 s.mark_viewed("a.txt", "hash-1");
513 s.unmark_viewed("a.txt");
514 assert!(!s.is_viewed("a.txt", "hash-1"));
515 }
516
517 #[test]
518 fn clear_viewed_drops_every_mark() {
519 let mut s = Session::default();
520 s.mark_viewed("a.txt", "hash-1");
521 s.mark_viewed("b.txt", "hash-2");
522 s.clear_viewed();
523 assert!(!s.is_viewed("a.txt", "hash-1"));
524 assert!(!s.is_viewed("b.txt", "hash-2"));
525 }
526
527 fn hunked_model() -> DiffModel {
529 DiffModel {
530 files: vec![FileDiff {
531 path: "src/auth.py".into(),
532 old_path: None,
533 status: FileStatus::Modified,
534 binary: false,
535 old_text: None,
536 new_text: None,
537 hunks: vec![Hunk {
538 id: HunkId("h1".into()),
539 old_start: 1,
540 old_lines: 3,
541 new_start: 1,
542 new_lines: 3,
543 context: String::new(),
544 lines: vec![
545 DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
546 DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
547 DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
548 DiffLine::new(LineKind::Context, Some(3), Some(3), "three".into()),
549 ],
550 }],
551 hashes: crate::model::HashCache::default(),
552 blobs: crate::model::BlobIds::default(),
553 }],
554 }
555 }
556
557 #[test]
558 fn anchor_with_matching_line_text_is_current() {
559 let mut a = anchor("src/auth.py", Some(2));
560 a.line_text = Some("TWO".to_owned());
561 assert!(!a.is_outdated(&hunked_model()));
562 a.line_text = None;
563 assert!(!a.is_outdated(&hunked_model()));
564 }
565
566 #[test]
567 fn anchor_with_drifted_line_text_is_outdated() {
568 let mut a = anchor("src/auth.py", Some(2));
569 a.line_text = Some("old text".to_owned());
570 assert!(a.is_outdated(&hunked_model()));
571 }
572
573 #[test]
574 fn anchor_to_a_departed_line_is_outdated() {
575 let a = anchor("src/auth.py", Some(99));
576 assert!(a.is_outdated(&hunked_model()));
577 }
578
579 #[test]
580 fn old_side_anchor_checks_the_old_line() {
581 let mut a = anchor("src/auth.py", Some(2));
582 a.on_old_side = true;
583 a.line_text = Some("two".to_owned());
584 assert!(!a.is_outdated(&hunked_model()));
585 a.line_text = Some("TWO".to_owned());
586 assert!(a.is_outdated(&hunked_model()), "old side carries 'two'");
587 }
588
589 #[test]
590 fn range_anchor_judges_drift_on_its_end_line() {
591 let mut a = anchor("src/auth.py", Some(1));
592 a.line_end = Some(3);
593 a.line_text = Some("three".to_owned());
594 assert!(!a.is_outdated(&hunked_model()), "end line still matches");
595 a.line_text = Some("changed".to_owned());
596 assert!(a.is_outdated(&hunked_model()), "end line drifted");
597 }
598
599 #[test]
600 fn file_level_anchor_is_outdated_only_when_the_file_departs() {
601 assert!(!anchor("src/auth.py", None).is_outdated(&hunked_model()));
602 assert!(anchor("gone.py", None).is_outdated(&hunked_model()));
603 }
604
605 #[test]
606 fn reconcile_drops_departed_and_changed_keeps_matching() {
607 let kept = file_diff("kept.txt", "stable content\n");
608 let changed = file_diff("changed.txt", "rewritten content\n");
609
610 let mut s = Session::default();
611 s.mark_viewed("kept.txt", &kept.content_hash());
612 s.mark_viewed("changed.txt", "hash-of-old-content");
613 s.mark_viewed("departed.txt", "whatever");
614 s.add_comment(
615 anchor("departed.txt", Some(3)),
616 "reviewer",
617 "still relevant",
618 );
619
620 s.reconcile(&model(vec![kept, changed]));
621
622 assert!(s.viewed.contains_key("kept.txt"));
623 assert!(!s.viewed.contains_key("changed.txt"));
624 assert!(!s.viewed.contains_key("departed.txt"));
625 assert_eq!(s.comments.len(), 1);
626 }
627}