Skip to main content

diffler_core/
session.rs

1//! Review session: comments and per-file viewed marks, reconciled against
2//! fresh diff models. Persistence lives in `store`.
3
4use std::collections::BTreeMap;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use serde::{Deserialize, Serialize};
8
9use crate::model::DiffModel;
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 the UI can mark the
42    /// comment outdated when the agent rewrites the line.
43    #[serde(default)]
44    pub line_text: Option<String>,
45}
46
47impl Anchor {
48    /// Whether the anchor no longer matches the model. Range comments
49    /// anchor to their end line: that is the line whose disappearance or
50    /// `line_text` drift marks them outdated. A line-less anchor is
51    /// outdated only once the whole file leaves the diff.
52    pub fn is_outdated(&self, model: &DiffModel) -> bool {
53        match self.line_end.or(self.line) {
54            Some(line) => match model.find_line(&self.file, line, self.on_old_side) {
55                Some(found) => self
56                    .line_text
57                    .as_deref()
58                    .is_some_and(|snap| snap != found.text),
59                None => true,
60            },
61            None => !model.files.iter().any(|f| f.path == self.file),
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct Comment {
68    pub id: String,
69    pub author: String,
70    /// Forge-side id once synced/posted; `None` for purely local comments.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub remote_id: Option<String>,
73    /// The forge's review-thread handle, where the forge has one — what
74    /// thread resolution posts against.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub thread_id: Option<String>,
77    pub anchor: Anchor,
78    pub body: String,
79    pub status: CommentStatus,
80    #[serde(default)]
81    pub replies: Vec<Reply>,
82    pub at: u64,
83}
84
85#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Session {
87    #[serde(default)]
88    pub comments: Vec<Comment>,
89    /// Per-file viewed marks: path -> content hash of the new side at the
90    /// time of marking. A changed hash means the file needs re-review.
91    #[serde(default)]
92    pub viewed: BTreeMap<String, String>,
93}
94
95pub fn now_unix() -> u64 {
96    SystemTime::now()
97        .duration_since(UNIX_EPOCH)
98        .map_or(0, |d| d.as_secs())
99}
100
101impl Session {
102    pub fn add_comment(&mut self, anchor: Anchor, author: &str, body: &str) -> &Comment {
103        self.comments.push(Comment {
104            remote_id: None,
105            thread_id: None,
106            id: uuid::Uuid::new_v4().to_string(),
107            author: author.to_owned(),
108            anchor,
109            body: body.to_owned(),
110            status: CommentStatus::Open,
111            replies: Vec::new(),
112            at: now_unix(),
113        });
114        // just pushed, so the vec is non-empty
115        #[allow(clippy::expect_used)]
116        self.comments.last().expect("just pushed")
117    }
118
119    fn comment_mut(&mut self, comment_id: &str) -> Option<&mut Comment> {
120        self.comments.iter_mut().find(|c| c.id == comment_id)
121    }
122
123    /// Remove the comment with `id`; `true` when something was deleted.
124    pub fn delete_comment(&mut self, id: &str) -> bool {
125        let before = self.comments.len();
126        self.comments.retain(|c| c.id != id);
127        self.comments.len() != before
128    }
129
130    pub fn reply(&mut self, comment_id: &str, author: &str, body: &str) -> bool {
131        let Some(comment) = self.comment_mut(comment_id) else {
132            return false;
133        };
134        comment.replies.push(Reply {
135            remote_id: None,
136            author: author.to_owned(),
137            body: body.to_owned(),
138            at: now_unix(),
139        });
140        if comment.status == CommentStatus::Open {
141            comment.status = CommentStatus::Replied;
142        }
143        true
144    }
145
146    pub fn resolve(&mut self, comment_id: &str) -> bool {
147        let Some(comment) = self.comment_mut(comment_id) else {
148            return false;
149        };
150        comment.status = CommentStatus::Resolved;
151        true
152    }
153
154    /// Replace a comment's body in place (status, replies, and anchor are
155    /// kept). No author: an edit corrects the existing comment, it doesn't
156    /// attribute a new one.
157    pub fn edit_comment(&mut self, comment_id: &str, body: &str) -> bool {
158        let Some(comment) = self.comment_mut(comment_id) else {
159            return false;
160        };
161        body.clone_into(&mut comment.body);
162        true
163    }
164
165    pub fn mark_viewed(&mut self, path: &str, hash: &str) {
166        self.viewed.insert(path.to_owned(), hash.to_owned());
167    }
168
169    pub fn unmark_viewed(&mut self, path: &str) {
170        self.viewed.remove(path);
171    }
172
173    /// Drop every viewed mark, sending all files back to the review pile.
174    pub fn clear_viewed(&mut self) {
175        self.viewed.clear();
176    }
177
178    /// A stale hash means the file changed since it was marked: not viewed
179    /// anymore (auto-reset semantics).
180    pub fn is_viewed(&self, path: &str, current_hash: &str) -> bool {
181        self.viewed.get(path).is_some_and(|h| h == current_hash)
182    }
183
184    /// Drop viewed marks for files that left the diff or whose content
185    /// changed since marking. Comments are kept: they stay useful (possibly
186    /// flagged outdated) even when their file moves on.
187    pub fn reconcile(&mut self, model: &DiffModel) {
188        let live: BTreeMap<&str, String> = model
189            .files
190            .iter()
191            .map(|f| (f.path.as_str(), f.content_hash()))
192            .collect();
193        self.viewed
194            .retain(|path, hash| live.get(path.as_str()).is_some_and(|h| h == hash));
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
201    use crate::test_support::{anchor, file_diff};
202
203    use super::*;
204
205    fn model(files: Vec<FileDiff>) -> DiffModel {
206        DiffModel { files }
207    }
208
209    #[test]
210    fn comment_lifecycle_open_replied_resolved() {
211        let mut s = Session::default();
212        let id = s
213            .add_comment(anchor("a.txt", Some(3)), "reviewer", "why?")
214            .id
215            .clone();
216        assert_eq!(s.comments[0].status, CommentStatus::Open);
217        assert!(s.reply(&id, "agent", "because"));
218        assert_eq!(s.comments[0].status, CommentStatus::Replied);
219        assert!(s.resolve(&id));
220        assert_eq!(s.comments[0].status, CommentStatus::Resolved);
221        assert!(
222            s.comments
223                .iter()
224                .all(|c| c.status == CommentStatus::Resolved)
225        );
226    }
227
228    #[test]
229    fn reply_to_missing_comment_returns_false() {
230        let mut s = Session::default();
231        assert!(!s.reply("nope", "agent", "hi"));
232    }
233
234    #[test]
235    fn resolve_missing_comment_returns_false() {
236        let mut s = Session::default();
237        assert!(!s.resolve("nope"));
238    }
239
240    #[test]
241    fn edit_comment_replaces_body_and_keeps_status() {
242        let mut s = Session::default();
243        let id = s
244            .add_comment(anchor("a.txt", Some(3)), "reviewer", "old body")
245            .id
246            .clone();
247        assert!(s.reply(&id, "agent", "ack"));
248        assert!(s.edit_comment(&id, "new body"));
249        let c = s.comments.iter().find(|c| c.id == id).expect("comment");
250        assert_eq!(c.body, "new body");
251        assert_eq!(c.status, CommentStatus::Replied, "status is untouched");
252        assert_eq!(c.replies.len(), 1, "replies are untouched");
253        assert!(!s.edit_comment("nope", "x"));
254    }
255
256    #[test]
257    fn unresolved_comments_filters_resolved_only() {
258        let mut s = Session::default();
259        let keep = s
260            .add_comment(anchor("a.txt", Some(3)), "reviewer", "open")
261            .id
262            .clone();
263        let done = s
264            .add_comment(anchor("a.txt", Some(3)), "reviewer", "done")
265            .id
266            .clone();
267        assert!(s.resolve(&done));
268        let unresolved: Vec<_> = s
269            .comments
270            .iter()
271            .filter(|c| c.status != CommentStatus::Resolved)
272            .map(|c| c.id.clone())
273            .collect();
274        assert_eq!(unresolved, vec![keep]);
275    }
276
277    #[test]
278    fn session_serializes_round_trip_with_range_anchor() {
279        let mut s = Session::default();
280        let mut range = anchor("a.txt", Some(3));
281        range.line_end = Some(7);
282        s.add_comment(range, "reviewer", "this whole block");
283        s.mark_viewed("b.txt", "hash-b");
284        let json = serde_json::to_string(&s).expect("serialize");
285        let back: Session = serde_json::from_str(&json).expect("deserialize");
286        assert_eq!(s, back);
287        assert_eq!(back.comments[0].anchor.line_end, Some(7));
288    }
289
290    #[test]
291    fn is_viewed_true_for_same_hash_false_after_change() {
292        let mut s = Session::default();
293        s.mark_viewed("a.txt", "hash-1");
294        assert!(s.is_viewed("a.txt", "hash-1"));
295        assert!(!s.is_viewed("a.txt", "hash-2"));
296        assert!(!s.is_viewed("other.txt", "hash-1"));
297    }
298
299    #[test]
300    fn unmark_viewed_removes_entry() {
301        let mut s = Session::default();
302        s.mark_viewed("a.txt", "hash-1");
303        s.unmark_viewed("a.txt");
304        assert!(!s.is_viewed("a.txt", "hash-1"));
305    }
306
307    #[test]
308    fn clear_viewed_drops_every_mark() {
309        let mut s = Session::default();
310        s.mark_viewed("a.txt", "hash-1");
311        s.mark_viewed("b.txt", "hash-2");
312        s.clear_viewed();
313        assert!(!s.is_viewed("a.txt", "hash-1"));
314        assert!(!s.is_viewed("b.txt", "hash-2"));
315    }
316
317    /// One file, one hunk: context(1/1), deleted(2), added(2), context(3/3).
318    fn hunked_model() -> DiffModel {
319        DiffModel {
320            files: vec![FileDiff {
321                path: "src/auth.py".into(),
322                old_path: None,
323                status: FileStatus::Modified,
324                binary: false,
325                old_text: None,
326                new_text: None,
327                hunks: vec![Hunk {
328                    id: HunkId("h1".into()),
329                    old_start: 1,
330                    old_lines: 3,
331                    new_start: 1,
332                    new_lines: 3,
333                    context: String::new(),
334                    lines: vec![
335                        DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
336                        DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
337                        DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
338                        DiffLine::new(LineKind::Context, Some(3), Some(3), "three".into()),
339                    ],
340                }],
341                hashes: crate::model::HashCache::default(),
342            }],
343        }
344    }
345
346    #[test]
347    fn anchor_with_matching_line_text_is_current() {
348        let mut a = anchor("src/auth.py", Some(2));
349        a.line_text = Some("TWO".to_owned());
350        assert!(!a.is_outdated(&hunked_model()));
351        // without a snapshot, a present line counts as current
352        a.line_text = None;
353        assert!(!a.is_outdated(&hunked_model()));
354    }
355
356    #[test]
357    fn anchor_with_drifted_line_text_is_outdated() {
358        let mut a = anchor("src/auth.py", Some(2));
359        a.line_text = Some("old text".to_owned());
360        assert!(a.is_outdated(&hunked_model()));
361    }
362
363    #[test]
364    fn anchor_to_a_departed_line_is_outdated() {
365        let a = anchor("src/auth.py", Some(99));
366        assert!(a.is_outdated(&hunked_model()));
367    }
368
369    #[test]
370    fn old_side_anchor_checks_the_old_line() {
371        let mut a = anchor("src/auth.py", Some(2));
372        a.on_old_side = true;
373        a.line_text = Some("two".to_owned());
374        assert!(!a.is_outdated(&hunked_model()));
375        a.line_text = Some("TWO".to_owned());
376        assert!(a.is_outdated(&hunked_model()), "old side carries 'two'");
377    }
378
379    #[test]
380    fn range_anchor_judges_drift_on_its_end_line() {
381        let mut a = anchor("src/auth.py", Some(1));
382        a.line_end = Some(3);
383        a.line_text = Some("three".to_owned());
384        assert!(!a.is_outdated(&hunked_model()), "end line still matches");
385        a.line_text = Some("changed".to_owned());
386        assert!(a.is_outdated(&hunked_model()), "end line drifted");
387    }
388
389    #[test]
390    fn file_level_anchor_is_outdated_only_when_the_file_departs() {
391        assert!(!anchor("src/auth.py", None).is_outdated(&hunked_model()));
392        assert!(anchor("gone.py", None).is_outdated(&hunked_model()));
393    }
394
395    #[test]
396    fn reconcile_drops_departed_and_changed_keeps_matching() {
397        let kept = file_diff("kept.txt", "stable content\n");
398        let changed = file_diff("changed.txt", "rewritten content\n");
399
400        let mut s = Session::default();
401        s.mark_viewed("kept.txt", &kept.content_hash());
402        s.mark_viewed("changed.txt", "hash-of-old-content");
403        s.mark_viewed("departed.txt", "whatever");
404        s.add_comment(
405            anchor("departed.txt", Some(3)),
406            "reviewer",
407            "still relevant",
408        );
409
410        s.reconcile(&model(vec![kept, changed]));
411
412        assert!(s.viewed.contains_key("kept.txt"));
413        assert!(!s.viewed.contains_key("changed.txt"));
414        assert!(!s.viewed.contains_key("departed.txt"));
415        // comments survive reconciliation untouched
416        assert_eq!(s.comments.len(), 1);
417    }
418}