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