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