magi-code 0.80.2

Repository-aware CLI coding agent for terminal work
Documentation
//! Worktree review data. All entrypoints perform blocking I/O and belong on workers.
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,
    },
}

/// Worker-owned, bounded by the same limits as a single snapshot.
#[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> {
    // Start without a snapshot: only saved comment paths need reanchoring.
    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,
    }
}