use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use chrono::Utc;
use crate::model::review::FileReview;
use crate::model::{
AnchorState, Comment, CommentType, DiffFile, FileStatus, LineSide, ReviewSession,
};
#[derive(Debug)]
pub struct ReviewEngine {
session: ReviewSession,
}
impl ReviewEngine {
pub fn new(session: ReviewSession) -> Self {
Self { session }
}
pub fn session(&self) -> &ReviewSession {
&self.session
}
pub fn session_mut(&mut self) -> &mut ReviewSession {
&mut self.session
}
pub fn apply_diff_files(&mut self, diff_files: &[DiffFile]) {
self.session.apply_diff_files(diff_files);
}
pub fn replace_session(&mut self, session: ReviewSession) {
self.session = session;
}
pub fn reset_with_diff(&mut self, session: ReviewSession, diff_files: &[DiffFile]) {
self.session = session;
self.session.apply_diff_files(diff_files);
}
pub fn reanchor_comments(
&mut self,
old_new_content: &HashMap<PathBuf, String>,
new_content_map: &HashMap<PathBuf, String>,
new_paths: &HashSet<PathBuf>,
path_filter_active: bool,
) {
crate::reanchor::reanchor_comments(
&mut self.session,
old_new_content,
new_content_map,
new_paths,
path_filter_active,
);
}
pub fn into_session(self) -> ReviewSession {
self.session
}
pub fn touch_file(&mut self, path: PathBuf, status: FileStatus) -> &mut FileReview {
self.session.add_file(path.clone(), status);
self.session.updated_at = Utc::now();
self.session
.get_file_mut(&path)
.expect("file was just registered via add_file")
}
pub fn add_line_comment(
&mut self,
path: PathBuf,
status: FileStatus,
line: u32,
comment: Comment,
) -> String {
let id = comment.id.clone();
let review = self.touch_file(path, status);
review.add_line_comment(line, comment);
id
}
pub fn add_file_comment(
&mut self,
path: PathBuf,
status: FileStatus,
comment: Comment,
) -> String {
let id = comment.id.clone();
let review = self.touch_file(path, status);
review.add_file_comment(comment);
id
}
pub fn try_add_line_comment(
&mut self,
path: &Path,
line: u32,
comment: Comment,
) -> Option<String> {
let id = comment.id.clone();
let review = self.session.get_file_mut(path)?;
review.add_line_comment(line, comment);
self.session.updated_at = Utc::now();
Some(id)
}
pub fn try_add_file_comment(&mut self, path: &Path, comment: Comment) -> Option<String> {
let id = comment.id.clone();
let review = self.session.get_file_mut(path)?;
review.add_file_comment(comment);
self.session.updated_at = Utc::now();
Some(id)
}
pub fn add_review_comment(&mut self, comment: Comment) -> String {
let id = comment.id.clone();
self.session.review_comments.push(comment);
self.session.updated_at = Utc::now();
id
}
pub fn remove_comment(&mut self, id: &str) -> bool {
let session = &mut self.session;
if let Some(pos) = session.review_comments.iter().position(|c| c.id == id) {
session.review_comments.remove(pos);
session.updated_at = Utc::now();
return true;
}
for file in session.files.values_mut() {
if let Some(pos) = file.file_comments.iter().position(|c| c.id == id) {
file.file_comments.remove(pos);
session.updated_at = Utc::now();
return true;
}
let mut target_line: Option<u32> = None;
for (line, comments) in file.line_comments.iter_mut() {
if let Some(pos) = comments.iter().position(|c| c.id == id) {
comments.remove(pos);
target_line = Some(*line);
break;
}
}
if let Some(line) = target_line {
if file.line_comments.get(&line).is_some_and(|v| v.is_empty()) {
file.line_comments.remove(&line);
}
session.updated_at = Utc::now();
return true;
}
if let Some(pos) = file.orphaned_comments.iter().position(|c| c.id == id) {
file.orphaned_comments.remove(pos);
session.updated_at = Utc::now();
return true;
}
}
false
}
pub fn edit_comment(&mut self, id: &str, new_body: String, new_type: CommentType) -> bool {
let session = &mut self.session;
let apply = |c: &mut Comment| {
c.content = new_body.clone();
c.comment_type = new_type.clone();
};
if let Some(c) = session.review_comments.iter_mut().find(|c| c.id == id) {
apply(c);
session.updated_at = Utc::now();
return true;
}
for file in session.files.values_mut() {
if let Some(c) = file.file_comments.iter_mut().find(|c| c.id == id) {
apply(c);
session.updated_at = Utc::now();
return true;
}
for comments in file.line_comments.values_mut() {
if let Some(c) = comments.iter_mut().find(|c| c.id == id) {
apply(c);
session.updated_at = Utc::now();
return true;
}
}
if let Some(c) = file.orphaned_comments.iter_mut().find(|c| c.id == id) {
apply(c);
session.updated_at = Utc::now();
return true;
}
}
false
}
pub fn clear_comments(&mut self) -> (usize, usize) {
let (cleared, unreviewed) = self.session.clear_comments();
if cleared > 0 || unreviewed > 0 {
self.session.updated_at = Utc::now();
}
(cleared, unreviewed)
}
pub fn reanchor_orphan(
&mut self,
path: &Path,
orphan_idx: usize,
dest_line: u32,
dest_side: LineSide,
) -> bool {
let Some(review) = self.session.get_file_mut(path) else {
return false;
};
if orphan_idx >= review.orphaned_comments.len() {
return false;
}
let mut comment = review.orphaned_comments.remove(orphan_idx);
comment.anchor = Some(AnchorState::Anchored {
line: dest_line,
side: dest_side,
reanchored_at: Some(Utc::now()),
});
comment.side = Some(dest_side);
review
.line_comments
.entry(dest_line)
.or_default()
.push(comment);
self.session.updated_at = Utc::now();
true
}
pub fn line_comments_for(&self, path: &Path) -> std::collections::HashMap<u32, Vec<Comment>> {
self.session
.files
.get(path)
.map(|r| r.line_comments.clone())
.unwrap_or_default()
}
}
#[cfg(any(test, feature = "test-support"))]
impl ReviewEngine {
pub fn seed_anchored_line_comment(
&mut self,
path: PathBuf,
status: FileStatus,
line: u32,
comment: Comment,
) -> String {
let id = comment.id.clone();
self.session.add_file(path.clone(), status);
let review = self
.session
.get_file_mut(&path)
.expect("file was just registered via add_file");
review.add_line_comment(line, comment);
id
}
pub fn seed_orphaned_comment(
&mut self,
path: PathBuf,
status: FileStatus,
comment: Comment,
) -> String {
let id = comment.id.clone();
self.session.add_file(path.clone(), status);
let review = self
.session
.get_file_mut(&path)
.expect("file was just registered via add_file");
review.orphaned_comments.push(comment);
id
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::FileStatus;
use crate::model::comment::{AnchorState, Comment, CommentType, LineSide};
use crate::model::review::SessionDiffSource;
use chrono::Duration;
fn make_session() -> ReviewSession {
ReviewSession::new(
PathBuf::from("/tmp/repo"),
"abc123".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
)
}
fn make_comment(body: &str) -> Comment {
Comment::new(body.to_string(), CommentType::Note, Some(LineSide::New))
}
#[test]
fn session_mut_exposes_owned_session() {
let session = make_session();
let original_base = session.base_commit.clone();
let mut engine = ReviewEngine::new(session);
engine.session_mut().base_commit = "xyz999".to_string();
assert_ne!(engine.session().base_commit, original_base);
assert_eq!(engine.session().base_commit, "xyz999");
}
#[test]
fn apply_diff_files_delegates_to_session() {
let mut engine = ReviewEngine::new(make_session());
let df = DiffFile {
old_path: None,
new_path: Some(PathBuf::from("src/lib.rs")),
status: FileStatus::Added,
hunks: vec![],
is_binary: false,
is_too_large: false,
is_commit_message: false,
};
engine.apply_diff_files(std::slice::from_ref(&df));
assert!(
engine
.session()
.files
.contains_key(&PathBuf::from("src/lib.rs"))
);
}
#[test]
fn reanchor_comments_delegates_to_core_reanchor() {
let mut engine = ReviewEngine::new(make_session());
let old: HashMap<PathBuf, String> = HashMap::new();
let new: HashMap<PathBuf, String> = HashMap::new();
let paths: HashSet<PathBuf> = HashSet::new();
engine.reanchor_comments(&old, &new, &paths, false);
assert!(engine.session().files.is_empty());
}
#[test]
fn into_session_returns_owned_session() {
let mut session = make_session();
session.base_commit = "marker".to_string();
let engine = ReviewEngine::new(session);
let unwrapped = engine.into_session();
assert_eq!(unwrapped.base_commit, "marker");
}
#[test]
fn touch_file_registers_and_bumps_updated_at() {
let mut engine = ReviewEngine::new(make_session());
let before = engine.session().updated_at;
let path = PathBuf::from("src/lib.rs");
let review = engine.touch_file(path.clone(), FileStatus::Added);
assert_eq!(review.path, path);
assert_eq!(review.status, FileStatus::Added);
assert!(engine.session().files.contains_key(&path));
assert!(engine.session().updated_at >= before);
engine.touch_file(path.clone(), FileStatus::Modified);
assert_eq!(engine.session().files.len(), 1);
assert_eq!(
engine.session().files.get(&path).unwrap().status,
FileStatus::Added
);
}
#[test]
fn add_line_comment_registers_file_and_returns_id() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/lib.rs");
let comment = make_comment("line comment");
let expected_id = comment.id.clone();
let id = engine.add_line_comment(path.clone(), FileStatus::Modified, 42, comment);
assert_eq!(id, expected_id);
let file = engine.session().files.get(&path).expect("file registered");
let comments = file.line_comments.get(&42).expect("line comment present");
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].content, "line comment");
}
#[test]
fn add_file_comment_registers_file_and_returns_id() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/lib.rs");
let comment = make_comment("file comment");
let expected_id = comment.id.clone();
let id = engine.add_file_comment(path.clone(), FileStatus::Modified, comment);
assert_eq!(id, expected_id);
let file = engine.session().files.get(&path).expect("file registered");
assert_eq!(file.file_comments.len(), 1);
assert_eq!(file.file_comments[0].content, "file comment");
}
#[test]
fn add_review_comment_pushes_and_returns_id() {
let mut engine = ReviewEngine::new(make_session());
let comment = make_comment("review-level");
let expected_id = comment.id.clone();
let id = engine.add_review_comment(comment);
assert_eq!(id, expected_id);
assert_eq!(engine.session().review_comments.len(), 1);
assert_eq!(engine.session().review_comments[0].content, "review-level");
}
#[test]
fn remove_comment_finds_across_scopes() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/lib.rs");
let review_id = engine.add_review_comment(make_comment("r"));
let file_id =
engine.add_file_comment(path.clone(), FileStatus::Modified, make_comment("f"));
let line_id =
engine.add_line_comment(path.clone(), FileStatus::Modified, 10, make_comment("l"));
assert!(engine.remove_comment(&review_id));
assert!(engine.session().review_comments.is_empty());
assert!(engine.remove_comment(&file_id));
assert!(
engine
.session()
.files
.get(&path)
.unwrap()
.file_comments
.is_empty()
);
assert!(engine.remove_comment(&line_id));
assert!(
!engine
.session()
.files
.get(&path)
.unwrap()
.line_comments
.contains_key(&10)
);
assert!(!engine.remove_comment("does-not-exist"));
}
#[test]
fn edit_comment_updates_both_body_and_type() {
let mut engine = ReviewEngine::new(make_session());
let review_id = engine.add_review_comment(make_comment("initial"));
assert!(engine.edit_comment(&review_id, "updated".to_string(), CommentType::Praise,));
let comment = &engine.session().review_comments[0];
assert_eq!(comment.content, "updated");
assert_eq!(comment.comment_type, CommentType::Praise);
assert!(!engine.edit_comment("missing", "x".to_string(), CommentType::Note,));
}
#[test]
fn clear_comments_returns_counts_and_unreviews() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/lib.rs");
engine.add_review_comment(make_comment("r"));
engine.add_file_comment(path.clone(), FileStatus::Modified, make_comment("f"));
engine.add_line_comment(path.clone(), FileStatus::Modified, 1, make_comment("l"));
engine.session_mut().get_file_mut(&path).unwrap().reviewed = true;
let (cleared, unreviewed) = engine.clear_comments();
assert_eq!(cleared, 3);
assert_eq!(unreviewed, 1);
assert!(engine.session().review_comments.is_empty());
let fr = engine.session().files.get(&path).unwrap();
assert!(fr.file_comments.is_empty());
assert!(fr.line_comments.is_empty());
assert!(!fr.reviewed);
}
#[test]
fn line_comments_for_returns_snapshot_or_empty() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/lib.rs");
let empty = engine.line_comments_for(&path);
assert!(empty.is_empty());
engine.add_line_comment(path.clone(), FileStatus::Modified, 3, make_comment("hello"));
let map = engine.line_comments_for(&path);
assert_eq!(map.len(), 1);
assert_eq!(map.get(&3).unwrap()[0].content, "hello");
}
#[test]
fn try_add_line_comment_returns_none_for_unregistered_file() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/never.rs");
let updated_before = engine.session().updated_at;
let res = engine.try_add_line_comment(&path, 5, make_comment("nope"));
assert!(res.is_none());
assert!(engine.session().files.is_empty());
assert_eq!(engine.session().updated_at, updated_before);
engine.touch_file(path.clone(), FileStatus::Modified);
let after_touch = engine.session().updated_at;
let id = engine.try_add_line_comment(&path, 5, make_comment("yes"));
assert!(id.is_some());
assert!(engine.session().updated_at >= after_touch);
assert_eq!(
engine
.session()
.files
.get(&path)
.unwrap()
.line_comments
.get(&5)
.unwrap()[0]
.content,
"yes"
);
}
#[test]
fn replace_session_swaps_in_new_session() {
let mut session_a = make_session();
session_a.add_file(PathBuf::from("src/lib.rs"), FileStatus::Added);
let mut engine = ReviewEngine::new(session_a);
assert!(
engine
.session()
.files
.contains_key(&PathBuf::from("src/lib.rs"))
);
let mut session_b = make_session();
session_b.base_commit = "different".to_string();
engine.replace_session(session_b);
assert_eq!(engine.session().base_commit, "different");
assert!(
!engine
.session()
.files
.contains_key(&PathBuf::from("src/lib.rs"))
);
}
#[test]
fn reset_with_diff_replaces_and_registers() {
let mut engine = ReviewEngine::new(make_session());
engine
.session_mut()
.add_file(PathBuf::from("old/stale.rs"), FileStatus::Added);
let fresh = make_session();
let df = DiffFile {
old_path: None,
new_path: Some(PathBuf::from("src/new.rs")),
status: FileStatus::Added,
hunks: vec![],
is_binary: false,
is_too_large: false,
is_commit_message: false,
};
engine.reset_with_diff(fresh, std::slice::from_ref(&df));
assert!(
engine
.session()
.files
.contains_key(&PathBuf::from("src/new.rs"))
);
assert!(
!engine
.session()
.files
.contains_key(&PathBuf::from("old/stale.rs"))
);
}
#[test]
fn reset_with_diff_handles_renames() {
let mut engine = ReviewEngine::new(make_session());
let fresh = make_session();
let new_path = PathBuf::from("src/new.rs");
let renamed = DiffFile {
old_path: Some(PathBuf::from("src/old.rs")),
new_path: Some(new_path.clone()),
status: FileStatus::Renamed,
hunks: vec![],
is_binary: false,
is_too_large: false,
is_commit_message: false,
};
engine.reset_with_diff(fresh, std::slice::from_ref(&renamed));
assert!(engine.session().files.contains_key(&new_path));
}
#[test]
fn try_add_file_comment_returns_none_for_unregistered_file() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/never.rs");
let updated_before = engine.session().updated_at;
let res = engine.try_add_file_comment(&path, make_comment("nope"));
assert!(res.is_none());
assert!(engine.session().files.is_empty());
assert_eq!(engine.session().updated_at, updated_before);
engine.touch_file(path.clone(), FileStatus::Modified);
let id = engine.try_add_file_comment(&path, make_comment("yes"));
assert!(id.is_some());
assert_eq!(
engine.session().files.get(&path).unwrap().file_comments[0].content,
"yes"
);
}
#[test]
fn seed_anchored_line_comment_preserves_timestamps_and_anchor() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/lib.rs");
let created_at = Utc::now() - Duration::days(365);
let reanchored_at = Utc::now() - Duration::days(30);
let mut comment = make_comment("seeded");
comment.created_at = created_at;
comment.anchor = Some(AnchorState::Anchored {
line: 7,
side: LineSide::New,
reanchored_at: Some(reanchored_at),
});
let expected_id = comment.id.clone();
let updated_before = engine.session().updated_at;
let id = engine.seed_anchored_line_comment(path.clone(), FileStatus::Modified, 7, comment);
assert_eq!(id, expected_id);
let stored = &engine
.session()
.files
.get(&path)
.unwrap()
.line_comments
.get(&7)
.unwrap()[0];
assert_eq!(stored.created_at, created_at);
match stored.anchor.as_ref().unwrap() {
AnchorState::Anchored {
line,
side,
reanchored_at: stamped,
} => {
assert_eq!(*line, 7);
assert_eq!(*side, LineSide::New);
assert_eq!(*stamped, Some(reanchored_at));
}
other => panic!("expected anchored state, got {other:?}"),
}
assert_eq!(
engine.session().updated_at,
updated_before,
"seed must not bump updated_at"
);
}
#[test]
fn seed_orphaned_comment_preserves_timestamps_and_anchor() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("src/lib.rs");
let created_at = Utc::now() - Duration::days(365);
let orphaned_at = Utc::now() - Duration::days(30);
let mut comment = make_comment("orphaned seed");
comment.created_at = created_at;
comment.anchor = Some(AnchorState::Orphaned {
was_line: 5,
was_side: LineSide::New,
last_seen_content: "x".into(),
orphaned_at: Some(orphaned_at),
});
let expected_id = comment.id.clone();
let updated_before = engine.session().updated_at;
let id = engine.seed_orphaned_comment(path.clone(), FileStatus::Modified, comment);
assert_eq!(id, expected_id);
let file = engine.session().files.get(&path).unwrap();
assert_eq!(file.orphaned_comments.len(), 1);
let stored = &file.orphaned_comments[0];
assert_eq!(stored.created_at, created_at);
match stored.anchor.as_ref().unwrap() {
AnchorState::Orphaned {
was_line,
was_side,
last_seen_content,
orphaned_at: stamped,
} => {
assert_eq!(*was_line, 5);
assert_eq!(*was_side, LineSide::New);
assert_eq!(last_seen_content, "x");
assert_eq!(*stamped, Some(orphaned_at));
}
other => panic!("expected orphaned state, got {other:?}"),
}
assert_eq!(
engine.session().updated_at,
updated_before,
"seed must not bump updated_at"
);
}
#[test]
fn reanchor_orphan_moves_and_bumps_updated_at() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("a.rs");
let mut comment = Comment::new("stale".into(), CommentType::Note, Some(LineSide::New));
comment.anchor = Some(AnchorState::Orphaned {
was_line: 5,
was_side: LineSide::New,
last_seen_content: "x".into(),
orphaned_at: Some(Utc::now() - Duration::seconds(60)),
});
let cid = comment.id.clone();
engine.seed_orphaned_comment(path.clone(), FileStatus::Modified, comment);
let before = engine.session().updated_at;
assert!(engine.reanchor_orphan(&path, 0, 2, LineSide::New));
let review = engine.session().files.get(&path).unwrap();
assert!(
review.orphaned_comments.is_empty(),
"orphan popped off orphaned_comments"
);
let line_comments = review
.line_comments
.get(&2)
.expect("comment now lives at the new line");
assert_eq!(line_comments.len(), 1);
assert_eq!(line_comments[0].id, cid);
match line_comments[0].anchor.as_ref().unwrap() {
AnchorState::Anchored {
line,
side,
reanchored_at,
} => {
assert_eq!(*line, 2);
assert_eq!(*side, LineSide::New);
assert!(
reanchored_at.is_some(),
"re-anchored comment must stamp reanchored_at"
);
}
other => panic!("re-anchored comment must be Anchored, got {other:?}"),
}
assert!(
engine.session().updated_at >= before,
"successful reanchor must bump updated_at"
);
}
#[test]
fn reanchor_stamps_timestamp_when_transitioning_from_orphaned() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("a.rs");
let mut comment = Comment::new("was gone".into(), CommentType::Note, Some(LineSide::New));
comment.anchor = Some(AnchorState::Orphaned {
was_line: 7,
was_side: LineSide::New,
last_seen_content: "gone-line".into(),
orphaned_at: Some(Utc::now() - Duration::minutes(10)),
});
engine.seed_orphaned_comment(path.clone(), FileStatus::Modified, comment);
let before = Utc::now();
assert!(engine.reanchor_orphan(&path, 0, 3, LineSide::New));
let review = engine.session().files.get(&path).unwrap();
assert!(review.orphaned_comments.is_empty());
let at_3 = review
.line_comments
.get(&3)
.expect("re-anchored comment lives at dest line");
assert_eq!(at_3.len(), 1);
match at_3[0].anchor.as_ref().unwrap() {
AnchorState::Anchored {
line,
side,
reanchored_at,
} => {
assert_eq!(*line, 3);
assert_eq!(*side, LineSide::New);
let stamped = reanchored_at.expect(
"Orphaned → Anchored transition MUST stamp reanchored_at \
so polling agents can observe the recovery",
);
assert!(
stamped >= before,
"reanchored_at should be a fresh timestamp, not reused"
);
}
other => panic!("expected Anchored, got {other:?}"),
}
}
#[test]
fn reanchor_orphan_returns_false_and_preserves_updated_at_on_miss() {
let mut engine = ReviewEngine::new(make_session());
let path = PathBuf::from("a.rs");
let before_unknown = engine.session().updated_at;
assert!(!engine.reanchor_orphan(&path, 0, 1, LineSide::New));
assert_eq!(
engine.session().updated_at,
before_unknown,
"miss must not bump updated_at"
);
engine
.session_mut()
.add_file(path.clone(), FileStatus::Modified);
let before_oob = engine.session().updated_at;
assert!(!engine.reanchor_orphan(&path, 99, 1, LineSide::New));
assert_eq!(
engine.session().updated_at,
before_oob,
"out-of-range orphan_idx must not bump updated_at"
);
}
}