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        self.mark_replied(comment_id)
141    }
142
143    pub fn comment(&self, id: &str) -> Option<&Comment> {
144        self.comments.iter().find(|comment| comment.id == id)
145    }
146
147    /// Flag a comment as addressed without writing anything into the thread.
148    /// An agent that already answered has said its piece; a second summary of
149    /// it is noise in the card.
150    pub fn mark_replied(&mut self, comment_id: &str) -> bool {
151        let Some(comment) = self.comment_mut(comment_id) else {
152            return false;
153        };
154        if comment.status == CommentStatus::Open {
155            comment.status = CommentStatus::Replied;
156        }
157        true
158    }
159
160    pub fn resolve(&mut self, comment_id: &str) -> bool {
161        let Some(comment) = self.comment_mut(comment_id) else {
162            return false;
163        };
164        comment.status = CommentStatus::Resolved;
165        true
166    }
167
168    /// Replace a comment's body in place (status, replies, and anchor are
169    /// kept). No author: an edit corrects the existing comment, it doesn't
170    /// attribute a new one.
171    pub fn edit_comment(&mut self, comment_id: &str, body: &str) -> bool {
172        let Some(comment) = self.comment_mut(comment_id) else {
173            return false;
174        };
175        body.clone_into(&mut comment.body);
176        true
177    }
178
179    pub fn mark_viewed(&mut self, path: &str, hash: &str) {
180        self.viewed.insert(path.to_owned(), hash.to_owned());
181    }
182
183    pub fn unmark_viewed(&mut self, path: &str) {
184        self.viewed.remove(path);
185    }
186
187    /// Drop every viewed mark, sending all files back to the review pile.
188    pub fn clear_viewed(&mut self) {
189        self.viewed.clear();
190    }
191
192    /// A stale hash means the file changed since it was marked: not viewed
193    /// anymore (auto-reset semantics).
194    pub fn is_viewed(&self, path: &str, current_hash: &str) -> bool {
195        self.viewed.get(path).is_some_and(|h| h == current_hash)
196    }
197
198    /// Drop viewed marks for files that left the diff or whose content
199    /// changed since marking. Comments are kept: they stay useful (possibly
200    /// flagged outdated) even when their file moves on.
201    pub fn reconcile(&mut self, model: &DiffModel) {
202        let live: BTreeMap<&str, String> = model
203            .files
204            .iter()
205            .map(|f| (f.path.as_str(), f.content_hash()))
206            .collect();
207        self.viewed
208            .retain(|path, hash| live.get(path.as_str()).is_some_and(|h| h == hash));
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
215    use crate::test_support::{anchor, file_diff};
216
217    use super::*;
218
219    fn model(files: Vec<FileDiff>) -> DiffModel {
220        DiffModel { files }
221    }
222
223    #[test]
224    fn comment_lifecycle_open_replied_resolved() {
225        let mut s = Session::default();
226        let id = s
227            .add_comment(anchor("a.txt", Some(3)), "reviewer", "why?")
228            .id
229            .clone();
230        assert_eq!(s.comments[0].status, CommentStatus::Open);
231        assert!(s.reply(&id, "agent", "because"));
232        assert_eq!(s.comments[0].status, CommentStatus::Replied);
233        assert!(s.resolve(&id));
234        assert_eq!(s.comments[0].status, CommentStatus::Resolved);
235        assert!(
236            s.comments
237                .iter()
238                .all(|c| c.status == CommentStatus::Resolved)
239        );
240    }
241
242    #[test]
243    fn reply_to_missing_comment_returns_false() {
244        let mut s = Session::default();
245        assert!(!s.reply("nope", "agent", "hi"));
246    }
247
248    #[test]
249    fn resolve_missing_comment_returns_false() {
250        let mut s = Session::default();
251        assert!(!s.resolve("nope"));
252    }
253
254    #[test]
255    fn edit_comment_replaces_body_and_keeps_status() {
256        let mut s = Session::default();
257        let id = s
258            .add_comment(anchor("a.txt", Some(3)), "reviewer", "old body")
259            .id
260            .clone();
261        assert!(s.reply(&id, "agent", "ack"));
262        assert!(s.edit_comment(&id, "new body"));
263        let c = s.comments.iter().find(|c| c.id == id).expect("comment");
264        assert_eq!(c.body, "new body");
265        assert_eq!(c.status, CommentStatus::Replied, "status is untouched");
266        assert_eq!(c.replies.len(), 1, "replies are untouched");
267        assert!(!s.edit_comment("nope", "x"));
268    }
269
270    #[test]
271    fn unresolved_comments_filters_resolved_only() {
272        let mut s = Session::default();
273        let keep = s
274            .add_comment(anchor("a.txt", Some(3)), "reviewer", "open")
275            .id
276            .clone();
277        let done = s
278            .add_comment(anchor("a.txt", Some(3)), "reviewer", "done")
279            .id
280            .clone();
281        assert!(s.resolve(&done));
282        let unresolved: Vec<_> = s
283            .comments
284            .iter()
285            .filter(|c| c.status != CommentStatus::Resolved)
286            .map(|c| c.id.clone())
287            .collect();
288        assert_eq!(unresolved, vec![keep]);
289    }
290
291    #[test]
292    fn session_serializes_round_trip_with_range_anchor() {
293        let mut s = Session::default();
294        let mut range = anchor("a.txt", Some(3));
295        range.line_end = Some(7);
296        s.add_comment(range, "reviewer", "this whole block");
297        s.mark_viewed("b.txt", "hash-b");
298        let json = serde_json::to_string(&s).expect("serialize");
299        let back: Session = serde_json::from_str(&json).expect("deserialize");
300        assert_eq!(s, back);
301        assert_eq!(back.comments[0].anchor.line_end, Some(7));
302    }
303
304    #[test]
305    fn is_viewed_true_for_same_hash_false_after_change() {
306        let mut s = Session::default();
307        s.mark_viewed("a.txt", "hash-1");
308        assert!(s.is_viewed("a.txt", "hash-1"));
309        assert!(!s.is_viewed("a.txt", "hash-2"));
310        assert!(!s.is_viewed("other.txt", "hash-1"));
311    }
312
313    #[test]
314    fn unmark_viewed_removes_entry() {
315        let mut s = Session::default();
316        s.mark_viewed("a.txt", "hash-1");
317        s.unmark_viewed("a.txt");
318        assert!(!s.is_viewed("a.txt", "hash-1"));
319    }
320
321    #[test]
322    fn clear_viewed_drops_every_mark() {
323        let mut s = Session::default();
324        s.mark_viewed("a.txt", "hash-1");
325        s.mark_viewed("b.txt", "hash-2");
326        s.clear_viewed();
327        assert!(!s.is_viewed("a.txt", "hash-1"));
328        assert!(!s.is_viewed("b.txt", "hash-2"));
329    }
330
331    /// One file, one hunk: context(1/1), deleted(2), added(2), context(3/3).
332    fn hunked_model() -> DiffModel {
333        DiffModel {
334            files: vec![FileDiff {
335                path: "src/auth.py".into(),
336                old_path: None,
337                status: FileStatus::Modified,
338                binary: false,
339                old_text: None,
340                new_text: None,
341                hunks: vec![Hunk {
342                    id: HunkId("h1".into()),
343                    old_start: 1,
344                    old_lines: 3,
345                    new_start: 1,
346                    new_lines: 3,
347                    context: String::new(),
348                    lines: vec![
349                        DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
350                        DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
351                        DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
352                        DiffLine::new(LineKind::Context, Some(3), Some(3), "three".into()),
353                    ],
354                }],
355                hashes: crate::model::HashCache::default(),
356            }],
357        }
358    }
359
360    #[test]
361    fn anchor_with_matching_line_text_is_current() {
362        let mut a = anchor("src/auth.py", Some(2));
363        a.line_text = Some("TWO".to_owned());
364        assert!(!a.is_outdated(&hunked_model()));
365        // without a snapshot, a present line counts as current
366        a.line_text = None;
367        assert!(!a.is_outdated(&hunked_model()));
368    }
369
370    #[test]
371    fn anchor_with_drifted_line_text_is_outdated() {
372        let mut a = anchor("src/auth.py", Some(2));
373        a.line_text = Some("old text".to_owned());
374        assert!(a.is_outdated(&hunked_model()));
375    }
376
377    #[test]
378    fn anchor_to_a_departed_line_is_outdated() {
379        let a = anchor("src/auth.py", Some(99));
380        assert!(a.is_outdated(&hunked_model()));
381    }
382
383    #[test]
384    fn old_side_anchor_checks_the_old_line() {
385        let mut a = anchor("src/auth.py", Some(2));
386        a.on_old_side = true;
387        a.line_text = Some("two".to_owned());
388        assert!(!a.is_outdated(&hunked_model()));
389        a.line_text = Some("TWO".to_owned());
390        assert!(a.is_outdated(&hunked_model()), "old side carries 'two'");
391    }
392
393    #[test]
394    fn range_anchor_judges_drift_on_its_end_line() {
395        let mut a = anchor("src/auth.py", Some(1));
396        a.line_end = Some(3);
397        a.line_text = Some("three".to_owned());
398        assert!(!a.is_outdated(&hunked_model()), "end line still matches");
399        a.line_text = Some("changed".to_owned());
400        assert!(a.is_outdated(&hunked_model()), "end line drifted");
401    }
402
403    #[test]
404    fn file_level_anchor_is_outdated_only_when_the_file_departs() {
405        assert!(!anchor("src/auth.py", None).is_outdated(&hunked_model()));
406        assert!(anchor("gone.py", None).is_outdated(&hunked_model()));
407    }
408
409    #[test]
410    fn reconcile_drops_departed_and_changed_keeps_matching() {
411        let kept = file_diff("kept.txt", "stable content\n");
412        let changed = file_diff("changed.txt", "rewritten content\n");
413
414        let mut s = Session::default();
415        s.mark_viewed("kept.txt", &kept.content_hash());
416        s.mark_viewed("changed.txt", "hash-of-old-content");
417        s.mark_viewed("departed.txt", "whatever");
418        s.add_comment(
419            anchor("departed.txt", Some(3)),
420            "reviewer",
421            "still relevant",
422        );
423
424        s.reconcile(&model(vec![kept, changed]));
425
426        assert!(s.viewed.contains_key("kept.txt"));
427        assert!(!s.viewed.contains_key("changed.txt"));
428        assert!(!s.viewed.contains_key("departed.txt"));
429        // comments survive reconciliation untouched
430        assert_eq!(s.comments.len(), 1);
431    }
432}