Skip to main content

diffler_core/
session.rs

1//! Review session: comments and per-file viewed marks.
2
3use 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    /// Forge-side id once synced/posted; `None` for purely local replies.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub remote_id: Option<String>,
27}
28
29/// Where a comment is anchored. `line` (and `line_end` for visual ranges)
30/// is the new-side line number unless the line is a deletion, then it is
31/// the old-side number with `on_old_side`.
32#[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    /// Snapshot of the anchored line's text, so we can mark the comment
42    /// outdated when the line changes.
43    #[serde(default)]
44    pub line_text: Option<String>,
45}
46
47impl Anchor {
48    /// `(line, line_end)`, `None` for a file-level anchor.
49    pub fn span(&self) -> Option<(u32, u32)> {
50        self.line.map(|line| (line, self.line_end.unwrap_or(line)))
51    }
52
53    /// A range anchor is judged on its end line. A line-less anchor is
54    /// outdated once its file leaves the diff.
55    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    /// Forge-side id once synced/posted; `None` for purely local comments.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub remote_id: Option<String>,
76    /// The forge's review-thread handle, used to resolve the thread.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub thread_id: Option<String>,
79    pub anchor: Anchor,
80    /// A walkthrough stop's title.
81    #[serde(default)]
82    pub title: Option<String>,
83    /// The anchor an agent wrote (`path#symbol`, `path:start-end`, `path`),
84    /// kept so we can resolve it again after the code moves.
85    #[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    /// Path to the new side's content hash when marked. A changed hash means
99    /// the file needs re-review.
100    #[serde(default)]
101    pub viewed: BTreeMap<String, String>,
102    /// Set only for a `ReviewSource::Walkthrough` session.
103    #[serde(default)]
104    pub walkthrough: Option<Walkthrough>,
105    /// Stop ids the reader has marked read, pruned to the walkthrough's
106    /// `stops` on every change.
107    #[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
117/// Unix milliseconds, to order two things made in the same second. Any
118/// seconds stamp is smaller, so mixed stamps still sort oldest first.
119pub 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    /// Remove one stop and its notes. `false` when there is no walkthrough or
154    /// `index` is out of range.
155    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    /// Flag a comment as addressed, writing nothing into the thread.
224    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    /// Replace a comment's body, keeping its author, status, replies and
243    /// anchor.
244    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    /// A stale hash means the file changed since it was marked.
265    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    /// Drop viewed marks for files that left the diff or changed. Comments
270    /// stay, flagged outdated where they drifted.
271    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    /// One file, one hunk: context(1/1), deleted(2), added(2), context(3/3).
528    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}