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