mod git;
mod store;
#[cfg(test)]
mod tests;
use crate::cancellation::AgentCancellation;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
struct ReviewReadOptions<'a> {
cancellation: &'a AgentCancellation,
deadline: Instant,
}
impl<'a> ReviewReadOptions<'a> {
fn new(cancellation: &'a AgentCancellation) -> Self {
Self {
cancellation,
deadline: Instant::now() + Duration::from_secs(5),
}
}
fn check(&self) -> Result<(), String> {
self.cancellation.check().map_err(|e| e.to_string())?;
if Instant::now() >= self.deadline {
return Err("Review scan deadline exceeded".into());
}
Ok(())
}
fn remaining(&self) -> Result<Duration, String> {
self.check()?;
Ok(self.deadline.saturating_duration_since(Instant::now()))
}
}
#[derive(Clone, Debug)]
pub(crate) struct ReviewAnchor {
pub text: String,
pub before: Option<String>,
pub after: Option<String>,
}
impl ReviewAnchor {
pub(crate) fn capture(file: &ReviewFile, side: ReviewSide, line: usize) -> Option<Self> {
if file.notice.is_some() {
return None;
}
let lines: Vec<_> = source(file, side).lines().collect();
let index = line.checked_sub(1)?;
Some(Self {
text: lines.get(index)?.to_string(),
before: index
.checked_sub(1)
.and_then(|i| lines.get(i))
.map(|s| s.to_string()),
after: lines.get(index + 1).map(|s| s.to_string()),
})
}
}
const MAX_FILES: usize = 128;
const MAX_SOURCE: usize = 128 * 1024;
const MAX_TOTAL_SOURCE: usize = 4 * 1024 * 1024;
const MAX_CONTEXT: usize = 64 * 1024;
#[derive(Clone, Debug)]
pub(crate) struct ReviewSnapshot {
pub root: PathBuf,
pub files: Vec<ReviewFile>,
pub comments: Vec<ReviewComment>,
}
#[derive(Clone, Debug)]
pub(crate) struct ReviewFile {
pub path: String,
pub original: String,
pub current: String,
pub rows: Vec<ReviewLine>,
pub notice: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct ReviewLine {
pub old_line: Option<usize>,
pub new_line: Option<usize>,
pub text: String,
pub kind: ReviewLineKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ReviewLineKind {
Context,
Added,
Removed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum ReviewSide {
Original,
Changed,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ReviewComment {
pub id: String,
pub path: String,
pub side: ReviewSide,
pub line: usize,
pub anchor: String,
pub text: String,
pub stale: bool,
pub resolved: bool,
}
pub(crate) enum CommentChange {
Save {
id: Option<String>,
path: String,
side: ReviewSide,
line: usize,
text: String,
anchor: Option<ReviewAnchor>,
},
Delete(String),
Resolve {
id: String,
resolved: bool,
},
}
#[derive(Default)]
pub(crate) struct SnapshotCache {
root: PathBuf,
head: String,
files: Vec<ReviewFile>,
}
pub(crate) fn load_snapshot(
cwd: &Path,
cancellation: &AgentCancellation,
cache: &mut SnapshotCache,
) -> Result<ReviewSnapshot, String> {
let options = ReviewReadOptions::new(cancellation);
let root = git::root(cwd, &options)?.ok_or_else(|| "Not a Git worktree".to_owned())?;
let state = crate::config::McPaths::resolve_read_only()
.map_err(|e| e.to_string())?
.state;
let mut files = git::files_cached(&root, &options, cache)?;
let comments = store::load_reanchored(&state, &root, &mut files, &options)?;
Ok(ReviewSnapshot {
root,
files,
comments,
})
}
#[cfg(test)]
fn snapshot(
root: &Path,
state: &Path,
options: &ReviewReadOptions<'_>,
) -> Result<ReviewSnapshot, String> {
let mut files = git::files(root, options)?;
let comments = store::load_reanchored(state, root, &mut files, options)?;
Ok(ReviewSnapshot {
root: root.to_owned(),
files,
comments,
})
}
pub(crate) fn save_comment(
cwd: &Path,
change: CommentChange,
cancellation: &AgentCancellation,
) -> Result<(), String> {
let options = ReviewReadOptions::new(cancellation);
let root = git::root(cwd, &options)?.ok_or_else(|| "Not a Git worktree".to_owned())?;
let state = crate::config::McPaths::resolve()
.map_err(|e| e.to_string())?
.state;
store::save(&state, &root, change, &options)
}
pub(crate) fn comments_context(
cwd: &Path,
cancellation: &AgentCancellation,
) -> Result<String, String> {
let options = ReviewReadOptions::new(cancellation);
let root = git::root(cwd, &options)?.ok_or("Not a Git worktree")?;
let state = crate::config::McPaths::resolve_read_only()
.map_err(|e| e.to_string())?
.state;
render_comments_context(&root, &state, &options)
}
fn render_comments_context(
root: &Path,
state: &Path,
options: &ReviewReadOptions<'_>,
) -> Result<String, String> {
let comments = store::load_reanchored(state, root, &mut Vec::new(), options)?;
options.cancellation.check().map_err(|e| e.to_string())?;
Ok(render_comments(&comments))
}
fn render_comments(comments: &[ReviewComment]) -> String {
let mut output = String::new();
for comment in comments.iter().filter(|c| !c.resolved) {
output.push_str(&format!(
"Review comment {} at {} {:?}:{}{}\nOriginal anchor: {}\n{}\n",
comment.id,
comment.path,
comment.side,
comment.line,
if comment.stale {
" (stale; verify location)"
} else {
" (not stale)"
},
comment.anchor,
comment.text
));
if output.len() > MAX_CONTEXT {
break;
}
}
if output.is_empty() {
return "No unresolved Diff comments.".into();
}
bound_context(output)
}
fn bound_context(mut text: String) -> String {
if text.len() > MAX_CONTEXT {
let mut end = MAX_CONTEXT;
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
text.push_str("\n[Review context truncated]\n");
}
text
}
fn source(file: &ReviewFile, side: ReviewSide) -> &str {
match side {
ReviewSide::Original => &file.original,
ReviewSide::Changed => &file.current,
}
}