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, BTreeSet};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use serde::{Deserialize, Serialize};
8
9use crate::model::DiffModel;
10use crate::walkthrough::Walkthrough;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum CommentStatus {
15    Open,
16    Replied,
17    Resolved,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct Reply {
22    pub author: String,
23    pub body: String,
24    pub at: u64,
25    /// Forge-side id once synced/posted; `None` for purely local replies.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub remote_id: Option<String>,
28}
29
30/// Where a comment is anchored. `line` (and `line_end` for visual ranges)
31/// is the new-side line number unless the line is a deletion, then it is
32/// the old-side number with `on_old_side`.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Anchor {
35    pub file: String,
36    #[serde(default)]
37    pub line: Option<u32>,
38    #[serde(default)]
39    pub line_end: Option<u32>,
40    #[serde(default)]
41    pub on_old_side: bool,
42    /// Snapshot of the anchored line's text, so the UI can mark the
43    /// comment outdated when the agent rewrites the line.
44    #[serde(default)]
45    pub line_text: Option<String>,
46}
47
48impl Anchor {
49    /// The rows this anchor covers, on whichever side it names: `line`
50    /// through `line_end` (or just `line` for a point anchor). `None` for a
51    /// file-level anchor with no line at all.
52    pub fn span(&self) -> Option<(u32, u32)> {
53        self.line.map(|line| (line, self.line_end.unwrap_or(line)))
54    }
55
56    /// Whether the anchor no longer matches the model. Range comments
57    /// anchor to their end line: that is the line whose disappearance or
58    /// `line_text` drift marks them outdated. A line-less anchor is
59    /// outdated only once the whole file leaves the diff.
60    pub fn is_outdated(&self, model: &DiffModel) -> bool {
61        match self.line_end.or(self.line) {
62            Some(line) => match model.find_line(&self.file, line, self.on_old_side) {
63                Some(found) => self
64                    .line_text
65                    .as_deref()
66                    .is_some_and(|snap| snap != found.text),
67                None => true,
68            },
69            None => !model.files.iter().any(|f| f.path == self.file),
70        }
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Comment {
76    pub id: String,
77    pub author: String,
78    /// Forge-side id once synced/posted; `None` for purely local comments.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub remote_id: Option<String>,
81    /// The forge's review-thread handle, where the forge has one: what
82    /// thread resolution posts against.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub thread_id: Option<String>,
85    pub anchor: Anchor,
86    /// The agent's own name for this comment: a walkthrough stop's title,
87    /// set when it is published.
88    #[serde(default)]
89    pub title: Option<String>,
90    /// The anchor an agent wrote (`path#symbol`, `path:start-end`, `path`),
91    /// kept so the worker can resolve it again after the code moves.
92    #[serde(default)]
93    pub anchor_ref: Option<String>,
94    pub body: String,
95    pub status: CommentStatus,
96    #[serde(default)]
97    pub replies: Vec<Reply>,
98    pub at: u64,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Session {
103    #[serde(default)]
104    pub comments: Vec<Comment>,
105    /// Per-file viewed marks: path -> content hash of the new side at the
106    /// time of marking. A changed hash means the file needs re-review.
107    #[serde(default)]
108    pub viewed: BTreeMap<String, String>,
109    /// The walkthrough this session is, when its source is
110    /// `ReviewSource::Walkthrough`. Every comment in `comments` above is this
111    /// walkthrough's: its stops, their notes, and every human reply made on
112    /// it. `None` for every other source.
113    #[serde(default)]
114    pub walkthrough: Option<Walkthrough>,
115    /// Walkthrough stops (comment ids) the reader has marked read. Pruned to
116    /// the current walkthrough's `stops` on every change, so a stop id from a
117    /// superseded revision never lingers.
118    #[serde(default)]
119    pub seen_stops: BTreeSet<String>,
120}
121
122pub fn now_unix() -> u64 {
123    SystemTime::now()
124        .duration_since(UNIX_EPOCH)
125        .map_or(0, |d| d.as_secs())
126}
127
128/// Unix milliseconds, for a stamp that also has to order two things made in
129/// the same second. A stamp in seconds is smaller than any of these, so the
130/// two sort together and the older one still reads as older.
131pub fn now_unix_millis() -> u64 {
132    SystemTime::now()
133        .duration_since(UNIX_EPOCH)
134        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
135}
136
137impl Session {
138    pub fn add_comment(&mut self, anchor: Anchor, author: &str, body: &str) -> &Comment {
139        self.comments.push(Comment {
140            remote_id: None,
141            thread_id: None,
142            id: uuid::Uuid::new_v4().to_string(),
143            author: author.to_owned(),
144            anchor,
145            title: None,
146            anchor_ref: None,
147            body: body.to_owned(),
148            status: CommentStatus::Open,
149            replies: Vec::new(),
150            at: now_unix(),
151        });
152        // just pushed, so the vec is non-empty
153        #[allow(clippy::expect_used)]
154        self.comments.last().expect("just pushed")
155    }
156
157    fn comment_mut(&mut self, comment_id: &str) -> Option<&mut Comment> {
158        self.comments.iter_mut().find(|c| c.id == comment_id)
159    }
160
161    /// Set this session's walkthrough, replacing whatever it held before,
162    /// and prune seen marks for stops it no longer carries.
163    pub fn set_walkthrough(&mut self, walkthrough: Walkthrough) {
164        self.walkthrough = Some(walkthrough);
165        self.prune_seen_stops();
166    }
167
168    /// Remove one stop and its notes, keeping the walkthrough and its other
169    /// stops. `false` when there is no walkthrough or `index` is out of range.
170    pub fn delete_stop(&mut self, index: usize) -> bool {
171        let Some(walkthrough) = self.walkthrough.as_ref() else {
172            return false;
173        };
174        let Some(stop_id) = walkthrough.stops.get(index).cloned() else {
175            return false;
176        };
177        let mut remove: BTreeSet<String> = walkthrough
178            .notes_by_stop(&self.comments)
179            .get(index)
180            .cloned()
181            .unwrap_or_default()
182            .into_iter()
183            .collect();
184        remove.insert(stop_id);
185        let Some(walkthrough) = self.walkthrough.as_mut() else {
186            return false;
187        };
188        walkthrough.stops.remove(index);
189        self.comments.retain(|c| !remove.contains(&c.id));
190        self.prune_seen_stops();
191        true
192    }
193
194    /// Drop a seen mark for a stop the walkthrough no longer carries: a stop
195    /// id from a superseded revision would otherwise linger in `seen_stops`
196    /// forever.
197    fn prune_seen_stops(&mut self) {
198        let stops: BTreeSet<&str> = self
199            .walkthrough
200            .iter()
201            .flat_map(|w| w.stops.iter().map(String::as_str))
202            .collect();
203        self.seen_stops.retain(|id| stops.contains(id.as_str()));
204    }
205
206    /// Mark a walkthrough stop read.
207    pub fn mark_stop_seen(&mut self, id: &str) {
208        self.seen_stops.insert(id.to_owned());
209    }
210
211    pub fn unmark_stop_seen(&mut self, id: &str) {
212        self.seen_stops.remove(id);
213    }
214
215    pub fn is_stop_seen(&self, id: &str) -> bool {
216        self.seen_stops.contains(id)
217    }
218
219    /// Remove the comment with `id`; `true` when something was deleted.
220    pub fn delete_comment(&mut self, id: &str) -> bool {
221        let before = self.comments.len();
222        self.comments.retain(|c| c.id != id);
223        self.comments.len() != before
224    }
225
226    pub fn reply(&mut self, comment_id: &str, author: &str, body: &str) -> bool {
227        let Some(comment) = self.comment_mut(comment_id) else {
228            return false;
229        };
230        comment.replies.push(Reply {
231            remote_id: None,
232            author: author.to_owned(),
233            body: body.to_owned(),
234            at: now_unix(),
235        });
236        self.mark_replied(comment_id)
237    }
238
239    pub fn comment(&self, id: &str) -> Option<&Comment> {
240        self.comments.iter().find(|comment| comment.id == id)
241    }
242
243    /// Flag a comment as addressed without writing anything into the thread.
244    /// An agent that already answered has said its piece; a second summary of
245    /// it is noise in the card.
246    pub fn mark_replied(&mut self, comment_id: &str) -> bool {
247        let Some(comment) = self.comment_mut(comment_id) else {
248            return false;
249        };
250        if comment.status == CommentStatus::Open {
251            comment.status = CommentStatus::Replied;
252        }
253        true
254    }
255
256    pub fn resolve(&mut self, comment_id: &str) -> bool {
257        let Some(comment) = self.comment_mut(comment_id) else {
258            return false;
259        };
260        comment.status = CommentStatus::Resolved;
261        true
262    }
263
264    /// Replace a comment's body in place (status, replies, and anchor are
265    /// kept). No author: an edit corrects the existing comment, it doesn't
266    /// attribute a new one.
267    pub fn edit_comment(&mut self, comment_id: &str, body: &str) -> bool {
268        let Some(comment) = self.comment_mut(comment_id) else {
269            return false;
270        };
271        body.clone_into(&mut comment.body);
272        true
273    }
274
275    pub fn mark_viewed(&mut self, path: &str, hash: &str) {
276        self.viewed.insert(path.to_owned(), hash.to_owned());
277    }
278
279    pub fn unmark_viewed(&mut self, path: &str) {
280        self.viewed.remove(path);
281    }
282
283    /// Drop every viewed mark, sending all files back to the review pile.
284    pub fn clear_viewed(&mut self) {
285        self.viewed.clear();
286    }
287
288    /// A stale hash means the file changed since it was marked: not viewed
289    /// anymore (auto-reset semantics).
290    pub fn is_viewed(&self, path: &str, current_hash: &str) -> bool {
291        self.viewed.get(path).is_some_and(|h| h == current_hash)
292    }
293
294    /// Drop viewed marks for files that left the diff or whose content
295    /// changed since marking. Comments are kept: they stay useful (possibly
296    /// flagged outdated) even when their file moves on.
297    pub fn reconcile(&mut self, model: &DiffModel) {
298        let live: BTreeMap<&str, String> = model
299            .files
300            .iter()
301            .map(|f| (f.path.as_str(), f.content_hash()))
302            .collect();
303        self.viewed
304            .retain(|path, hash| live.get(path.as_str()).is_some_and(|h| h == hash));
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use crate::model::{DiffLine, FileDiff, FileStatus, Hunk, HunkId, LineKind};
311    use crate::test_support::{anchor, file_diff};
312
313    use super::*;
314
315    fn model(files: Vec<FileDiff>) -> DiffModel {
316        DiffModel { files }
317    }
318
319    #[test]
320    fn comment_lifecycle_open_replied_resolved() {
321        let mut s = Session::default();
322        let id = s
323            .add_comment(anchor("a.txt", Some(3)), "reviewer", "why?")
324            .id
325            .clone();
326        assert_eq!(s.comments[0].status, CommentStatus::Open);
327        assert!(s.reply(&id, "agent", "because"));
328        assert_eq!(s.comments[0].status, CommentStatus::Replied);
329        assert!(s.resolve(&id));
330        assert_eq!(s.comments[0].status, CommentStatus::Resolved);
331        assert!(
332            s.comments
333                .iter()
334                .all(|c| c.status == CommentStatus::Resolved)
335        );
336    }
337
338    #[test]
339    fn reply_to_missing_comment_returns_false() {
340        let mut s = Session::default();
341        assert!(!s.reply("nope", "agent", "hi"));
342    }
343
344    #[test]
345    fn resolve_missing_comment_returns_false() {
346        let mut s = Session::default();
347        assert!(!s.resolve("nope"));
348    }
349
350    #[test]
351    fn edit_comment_replaces_body_and_keeps_status() {
352        let mut s = Session::default();
353        let id = s
354            .add_comment(anchor("a.txt", Some(3)), "reviewer", "old body")
355            .id
356            .clone();
357        assert!(s.reply(&id, "agent", "ack"));
358        assert!(s.edit_comment(&id, "new body"));
359        let c = s.comments.iter().find(|c| c.id == id).expect("comment");
360        assert_eq!(c.body, "new body");
361        assert_eq!(c.status, CommentStatus::Replied, "status is untouched");
362        assert_eq!(c.replies.len(), 1, "replies are untouched");
363        assert!(!s.edit_comment("nope", "x"));
364    }
365
366    #[test]
367    fn unresolved_comments_filters_resolved_only() {
368        let mut s = Session::default();
369        let keep = s
370            .add_comment(anchor("a.txt", Some(3)), "reviewer", "open")
371            .id
372            .clone();
373        let done = s
374            .add_comment(anchor("a.txt", Some(3)), "reviewer", "done")
375            .id
376            .clone();
377        assert!(s.resolve(&done));
378        let unresolved: Vec<_> = s
379            .comments
380            .iter()
381            .filter(|c| c.status != CommentStatus::Resolved)
382            .map(|c| c.id.clone())
383            .collect();
384        assert_eq!(unresolved, vec![keep]);
385    }
386
387    #[test]
388    fn session_serializes_round_trip_with_range_anchor() {
389        let mut s = Session::default();
390        let mut range = anchor("a.txt", Some(3));
391        range.line_end = Some(7);
392        s.add_comment(range, "reviewer", "this whole block");
393        s.mark_viewed("b.txt", "hash-b");
394        let json = serde_json::to_string(&s).expect("serialize");
395        let back: Session = serde_json::from_str(&json).expect("deserialize");
396        assert_eq!(s, back);
397        assert_eq!(back.comments[0].anchor.line_end, Some(7));
398    }
399
400    /// A review file written before the walkthrough field existed still
401    /// loads, with none, so an old `.diffler/reviews/*.json` keeps working
402    /// after an upgrade.
403    #[test]
404    fn a_session_with_the_old_boards_key_and_no_walkthrough_key_loads_with_none() {
405        let json = r#"{"comments":[],"viewed":{},"boards":[]}"#;
406        let s: Session = serde_json::from_str(json).expect("deserialize");
407        assert!(s.walkthrough.is_none());
408    }
409
410    /// A dedicated walkthrough session deserializes its walkthrough directly;
411    /// an older `comments` field on the object (the pre-source owned-id list)
412    /// is simply unknown and ignored.
413    #[test]
414    fn a_walkthrough_session_deserializes_its_walkthrough_and_ignores_a_stale_comments_field() {
415        let json = r#"{"comments":[],"viewed":{},"walkthrough":{"id":"w1","title":"tour","author":"agent","at":1,"stops":["stop-0"],"comments":["stop-0"]}}"#;
416        let s: Session = serde_json::from_str(json).expect("deserialize");
417        assert_eq!(s.walkthrough.expect("walkthrough").id, "w1");
418    }
419
420    /// A review file written before seen marks existed carries no
421    /// `seen_stops` key at all, and still has to load.
422    #[test]
423    fn a_session_with_no_seen_stops_key_loads_empty() {
424        let json = r#"{"comments":[],"viewed":{}}"#;
425        let s: Session = serde_json::from_str(json).expect("deserialize");
426        assert!(s.seen_stops.is_empty());
427    }
428
429    #[test]
430    fn mark_and_unmark_stop_seen_round_trip() {
431        let mut s = Session::default();
432        assert!(!s.is_stop_seen("stop-0"));
433        s.mark_stop_seen("stop-0");
434        assert!(s.is_stop_seen("stop-0"));
435        s.unmark_stop_seen("stop-0");
436        assert!(!s.is_stop_seen("stop-0"));
437    }
438
439    fn walkthrough(id: &str, stops: &[&str]) -> Walkthrough {
440        Walkthrough {
441            id: id.to_owned(),
442            title: "tour".to_owned(),
443            author: "agent".to_owned(),
444            at: 1,
445            stops: stops.iter().map(|s| (*s).to_owned()).collect(),
446            skipped: None,
447            summary: None,
448            rev: None,
449            about: crate::source::ReviewSource::WorkingTree,
450        }
451    }
452
453    /// Setting a walkthrough drops a seen mark for a stop it no longer
454    /// carries, so a superseded stop id never lingers.
455    #[test]
456    fn set_walkthrough_prunes_seen_marks_for_dropped_stops() {
457        let mut s = Session::default();
458        s.mark_stop_seen("stop-0");
459        s.mark_stop_seen("stop-1");
460        s.set_walkthrough(walkthrough("w1", &["stop-1"]));
461        assert!(!s.is_stop_seen("stop-0"), "stop-0 no longer exists");
462        assert!(s.is_stop_seen("stop-1"), "stop-1 survives the revision");
463    }
464
465    /// Setting a walkthrough replaces whatever this session held before, in
466    /// place: there is only ever one.
467    #[test]
468    fn set_walkthrough_replaces_whatever_was_there() {
469        let mut s = Session::default();
470        s.set_walkthrough(walkthrough("w1", &["a"]));
471        s.set_walkthrough(Walkthrough {
472            title: "revised".to_owned(),
473            at: 2,
474            ..walkthrough("w1", &["a"])
475        });
476        assert_eq!(s.walkthrough.expect("walkthrough").title, "revised");
477    }
478
479    fn agent_comment(id: &str, file: &str, line: u32, title: Option<&str>) -> Comment {
480        Comment {
481            id: id.to_owned(),
482            author: "agent".to_owned(),
483            remote_id: None,
484            thread_id: None,
485            anchor: anchor(file, Some(line)),
486            title: title.map(str::to_owned),
487            anchor_ref: Some(format!("{file}:{line}")),
488            body: "why".to_owned(),
489            status: CommentStatus::Open,
490            replies: Vec::new(),
491            at: 1,
492        }
493    }
494
495    fn human_comment(id: &str, file: &str, line: u32) -> Comment {
496        Comment {
497            author: "reviewer".to_owned(),
498            title: None,
499            anchor_ref: None,
500            ..agent_comment(id, file, line, None)
501        }
502    }
503
504    /// Deleting one stop drops its primary and every note anchored in its
505    /// region, keeps the rest of the walkthrough, and never touches a human
506    /// comment in the same spot.
507    #[test]
508    fn delete_stop_removes_its_primary_and_notes_but_keeps_a_human_comment_there() {
509        let mut s = Session::default();
510        s.comments
511            .push(agent_comment("stop-0", "a.txt", 1, Some("first")));
512        s.comments.push(agent_comment("note-0", "a.txt", 1, None));
513        s.comments.push(human_comment("human-0", "a.txt", 1));
514        s.comments
515            .push(agent_comment("stop-1", "b.txt", 1, Some("second")));
516        s.set_walkthrough(walkthrough("w1", &["stop-0", "stop-1"]));
517
518        assert!(s.delete_stop(0));
519
520        let walkthrough = s.walkthrough.as_ref().expect("the walkthrough is kept");
521        assert_eq!(walkthrough.stops, ["stop-1".to_owned()]);
522        let ids: Vec<&str> = s.comments.iter().map(|c| c.id.as_str()).collect();
523        assert_eq!(
524            ids,
525            ["human-0", "stop-1"],
526            "the human comment in stop-0's region survives"
527        );
528    }
529
530    #[test]
531    fn delete_stop_is_false_out_of_range_or_without_a_walkthrough() {
532        let mut s = Session::default();
533        assert!(!s.delete_stop(0), "no walkthrough at all");
534
535        s.comments
536            .push(agent_comment("stop-0", "a.txt", 1, Some("first")));
537        s.set_walkthrough(walkthrough("w1", &["stop-0"]));
538        assert!(!s.delete_stop(5), "past the end");
539    }
540
541    #[test]
542    fn is_viewed_true_for_same_hash_false_after_change() {
543        let mut s = Session::default();
544        s.mark_viewed("a.txt", "hash-1");
545        assert!(s.is_viewed("a.txt", "hash-1"));
546        assert!(!s.is_viewed("a.txt", "hash-2"));
547        assert!(!s.is_viewed("other.txt", "hash-1"));
548    }
549
550    #[test]
551    fn unmark_viewed_removes_entry() {
552        let mut s = Session::default();
553        s.mark_viewed("a.txt", "hash-1");
554        s.unmark_viewed("a.txt");
555        assert!(!s.is_viewed("a.txt", "hash-1"));
556    }
557
558    #[test]
559    fn clear_viewed_drops_every_mark() {
560        let mut s = Session::default();
561        s.mark_viewed("a.txt", "hash-1");
562        s.mark_viewed("b.txt", "hash-2");
563        s.clear_viewed();
564        assert!(!s.is_viewed("a.txt", "hash-1"));
565        assert!(!s.is_viewed("b.txt", "hash-2"));
566    }
567
568    /// One file, one hunk: context(1/1), deleted(2), added(2), context(3/3).
569    fn hunked_model() -> DiffModel {
570        DiffModel {
571            files: vec![FileDiff {
572                path: "src/auth.py".into(),
573                old_path: None,
574                status: FileStatus::Modified,
575                binary: false,
576                old_text: None,
577                new_text: None,
578                hunks: vec![Hunk {
579                    id: HunkId("h1".into()),
580                    old_start: 1,
581                    old_lines: 3,
582                    new_start: 1,
583                    new_lines: 3,
584                    context: String::new(),
585                    lines: vec![
586                        DiffLine::new(LineKind::Context, Some(1), Some(1), "one".into()),
587                        DiffLine::new(LineKind::Deleted, Some(2), None, "two".into()),
588                        DiffLine::new(LineKind::Added, None, Some(2), "TWO".into()),
589                        DiffLine::new(LineKind::Context, Some(3), Some(3), "three".into()),
590                    ],
591                }],
592                hashes: crate::model::HashCache::default(),
593                blobs: crate::model::BlobIds::default(),
594            }],
595        }
596    }
597
598    #[test]
599    fn anchor_with_matching_line_text_is_current() {
600        let mut a = anchor("src/auth.py", Some(2));
601        a.line_text = Some("TWO".to_owned());
602        assert!(!a.is_outdated(&hunked_model()));
603        // without a snapshot, a present line counts as current
604        a.line_text = None;
605        assert!(!a.is_outdated(&hunked_model()));
606    }
607
608    #[test]
609    fn anchor_with_drifted_line_text_is_outdated() {
610        let mut a = anchor("src/auth.py", Some(2));
611        a.line_text = Some("old text".to_owned());
612        assert!(a.is_outdated(&hunked_model()));
613    }
614
615    #[test]
616    fn anchor_to_a_departed_line_is_outdated() {
617        let a = anchor("src/auth.py", Some(99));
618        assert!(a.is_outdated(&hunked_model()));
619    }
620
621    #[test]
622    fn old_side_anchor_checks_the_old_line() {
623        let mut a = anchor("src/auth.py", Some(2));
624        a.on_old_side = true;
625        a.line_text = Some("two".to_owned());
626        assert!(!a.is_outdated(&hunked_model()));
627        a.line_text = Some("TWO".to_owned());
628        assert!(a.is_outdated(&hunked_model()), "old side carries 'two'");
629    }
630
631    #[test]
632    fn range_anchor_judges_drift_on_its_end_line() {
633        let mut a = anchor("src/auth.py", Some(1));
634        a.line_end = Some(3);
635        a.line_text = Some("three".to_owned());
636        assert!(!a.is_outdated(&hunked_model()), "end line still matches");
637        a.line_text = Some("changed".to_owned());
638        assert!(a.is_outdated(&hunked_model()), "end line drifted");
639    }
640
641    #[test]
642    fn file_level_anchor_is_outdated_only_when_the_file_departs() {
643        assert!(!anchor("src/auth.py", None).is_outdated(&hunked_model()));
644        assert!(anchor("gone.py", None).is_outdated(&hunked_model()));
645    }
646
647    #[test]
648    fn reconcile_drops_departed_and_changed_keeps_matching() {
649        let kept = file_diff("kept.txt", "stable content\n");
650        let changed = file_diff("changed.txt", "rewritten content\n");
651
652        let mut s = Session::default();
653        s.mark_viewed("kept.txt", &kept.content_hash());
654        s.mark_viewed("changed.txt", "hash-of-old-content");
655        s.mark_viewed("departed.txt", "whatever");
656        s.add_comment(
657            anchor("departed.txt", Some(3)),
658            "reviewer",
659            "still relevant",
660        );
661
662        s.reconcile(&model(vec![kept, changed]));
663
664        assert!(s.viewed.contains_key("kept.txt"));
665        assert!(!s.viewed.contains_key("changed.txt"));
666        assert!(!s.viewed.contains_key("departed.txt"));
667        // comments survive reconciliation untouched
668        assert_eq!(s.comments.len(), 1);
669    }
670}