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