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 }
450 }
451
452 #[test]
455 fn set_walkthrough_prunes_seen_marks_for_dropped_stops() {
456 let mut s = Session::default();
457 s.mark_stop_seen("stop-0");
458 s.mark_stop_seen("stop-1");
459 s.set_walkthrough(walkthrough("w1", &["stop-1"]));
460 assert!(!s.is_stop_seen("stop-0"), "stop-0 no longer exists");
461 assert!(s.is_stop_seen("stop-1"), "stop-1 survives the revision");
462 }
463
464 #[test]
467 fn set_walkthrough_replaces_whatever_was_there() {
468 let mut s = Session::default();
469 s.set_walkthrough(walkthrough("w1", &["a"]));
470 s.set_walkthrough(Walkthrough {
471 title: "revised".to_owned(),
472 at: 2,
473 ..walkthrough("w1", &["a"])
474 });
475 assert_eq!(s.walkthrough.expect("walkthrough").title, "revised");
476 }
477
478 fn agent_comment(id: &str, file: &str, line: u32, title: Option<&str>) -> Comment {
479 Comment {
480 id: id.to_owned(),
481 author: "agent".to_owned(),
482 remote_id: None,
483 thread_id: None,
484 anchor: anchor(file, Some(line)),
485 title: title.map(str::to_owned),
486 anchor_ref: Some(format!("{file}:{line}")),
487 body: "why".to_owned(),
488 status: CommentStatus::Open,
489 replies: Vec::new(),
490 at: 1,
491 }
492 }
493
494 fn human_comment(id: &str, file: &str, line: u32) -> Comment {
495 Comment {
496 author: "reviewer".to_owned(),
497 title: None,
498 anchor_ref: None,
499 ..agent_comment(id, file, line, None)
500 }
501 }
502
503 #[test]
507 fn delete_stop_removes_its_primary_and_notes_but_keeps_a_human_comment_there() {
508 let mut s = Session::default();
509 s.comments
510 .push(agent_comment("stop-0", "a.txt", 1, Some("first")));
511 s.comments.push(agent_comment("note-0", "a.txt", 1, None));
512 s.comments.push(human_comment("human-0", "a.txt", 1));
513 s.comments
514 .push(agent_comment("stop-1", "b.txt", 1, Some("second")));
515 s.set_walkthrough(walkthrough("w1", &["stop-0", "stop-1"]));
516
517 assert!(s.delete_stop(0));
518
519 let walkthrough = s.walkthrough.as_ref().expect("the walkthrough is kept");
520 assert_eq!(walkthrough.stops, ["stop-1".to_owned()]);
521 let ids: Vec<&str> = s.comments.iter().map(|c| c.id.as_str()).collect();
522 assert_eq!(
523 ids,
524 ["human-0", "stop-1"],
525 "the human comment in stop-0's region survives"
526 );
527 }
528
529 #[test]
530 fn delete_stop_is_false_out_of_range_or_without_a_walkthrough() {
531 let mut s = Session::default();
532 assert!(!s.delete_stop(0), "no walkthrough at all");
533
534 s.comments
535 .push(agent_comment("stop-0", "a.txt", 1, Some("first")));
536 s.set_walkthrough(walkthrough("w1", &["stop-0"]));
537 assert!(!s.delete_stop(5), "past the end");
538 }
539
540 #[test]
541 fn is_viewed_true_for_same_hash_false_after_change() {
542 let mut s = Session::default();
543 s.mark_viewed("a.txt", "hash-1");
544 assert!(s.is_viewed("a.txt", "hash-1"));
545 assert!(!s.is_viewed("a.txt", "hash-2"));
546 assert!(!s.is_viewed("other.txt", "hash-1"));
547 }
548
549 #[test]
550 fn unmark_viewed_removes_entry() {
551 let mut s = Session::default();
552 s.mark_viewed("a.txt", "hash-1");
553 s.unmark_viewed("a.txt");
554 assert!(!s.is_viewed("a.txt", "hash-1"));
555 }
556
557 #[test]
558 fn clear_viewed_drops_every_mark() {
559 let mut s = Session::default();
560 s.mark_viewed("a.txt", "hash-1");
561 s.mark_viewed("b.txt", "hash-2");
562 s.clear_viewed();
563 assert!(!s.is_viewed("a.txt", "hash-1"));
564 assert!(!s.is_viewed("b.txt", "hash-2"));
565 }
566
567 fn hunked_model() -> DiffModel {
569 DiffModel {
570 files: vec![FileDiff {
571 path: "src/auth.py".into(),
572 old_path: None,
573 status: FileStatus::Modified,
574 binary: false,
575 old_text: None,
576 new_text: None,
577 hunks: vec![Hunk {
578 id: HunkId("h1".into()),
579 old_start: 1,
580 old_lines: 3,
581 new_start: 1,
582 new_lines: 3,
583 context: String::new(),
584 lines: vec![
585 DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
586 DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
587 DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
588 DiffLine::new(LineKind::Context, Some(3), Some(3), "three".into()),
589 ],
590 }],
591 hashes: crate::model::HashCache::default(),
592 }],
593 }
594 }
595
596 #[test]
597 fn anchor_with_matching_line_text_is_current() {
598 let mut a = anchor("src/auth.py", Some(2));
599 a.line_text = Some("TWO".to_owned());
600 assert!(!a.is_outdated(&hunked_model()));
601 a.line_text = None;
603 assert!(!a.is_outdated(&hunked_model()));
604 }
605
606 #[test]
607 fn anchor_with_drifted_line_text_is_outdated() {
608 let mut a = anchor("src/auth.py", Some(2));
609 a.line_text = Some("old text".to_owned());
610 assert!(a.is_outdated(&hunked_model()));
611 }
612
613 #[test]
614 fn anchor_to_a_departed_line_is_outdated() {
615 let a = anchor("src/auth.py", Some(99));
616 assert!(a.is_outdated(&hunked_model()));
617 }
618
619 #[test]
620 fn old_side_anchor_checks_the_old_line() {
621 let mut a = anchor("src/auth.py", Some(2));
622 a.on_old_side = true;
623 a.line_text = Some("two".to_owned());
624 assert!(!a.is_outdated(&hunked_model()));
625 a.line_text = Some("TWO".to_owned());
626 assert!(a.is_outdated(&hunked_model()), "old side carries 'two'");
627 }
628
629 #[test]
630 fn range_anchor_judges_drift_on_its_end_line() {
631 let mut a = anchor("src/auth.py", Some(1));
632 a.line_end = Some(3);
633 a.line_text = Some("three".to_owned());
634 assert!(!a.is_outdated(&hunked_model()), "end line still matches");
635 a.line_text = Some("changed".to_owned());
636 assert!(a.is_outdated(&hunked_model()), "end line drifted");
637 }
638
639 #[test]
640 fn file_level_anchor_is_outdated_only_when_the_file_departs() {
641 assert!(!anchor("src/auth.py", None).is_outdated(&hunked_model()));
642 assert!(anchor("gone.py", None).is_outdated(&hunked_model()));
643 }
644
645 #[test]
646 fn reconcile_drops_departed_and_changed_keeps_matching() {
647 let kept = file_diff("kept.txt", "stable content\n");
648 let changed = file_diff("changed.txt", "rewritten content\n");
649
650 let mut s = Session::default();
651 s.mark_viewed("kept.txt", &kept.content_hash());
652 s.mark_viewed("changed.txt", "hash-of-old-content");
653 s.mark_viewed("departed.txt", "whatever");
654 s.add_comment(
655 anchor("departed.txt", Some(3)),
656 "reviewer",
657 "still relevant",
658 );
659
660 s.reconcile(&model(vec![kept, changed]));
661
662 assert!(s.viewed.contains_key("kept.txt"));
663 assert!(!s.viewed.contains_key("changed.txt"));
664 assert!(!s.viewed.contains_key("departed.txt"));
665 assert_eq!(s.comments.len(), 1);
667 }
668}