magi-code 0.80.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::*;
use crate::persistence::{CrossProcessFileLock, atomic_write_with_permissions};
use sha2::{Digest, Sha256};
use std::{fs, io::Read, time::Duration};

const MAX_STORE: usize = 1024 * 1024;
const MAX_COMMENTS: usize = 256;
const MAX_COMMENT_TEXT: usize = 8192;

#[derive(Clone, Serialize, Deserialize)]
struct StoredComment {
    comment: ReviewComment,
    before: Option<String>,
    after: Option<String>,
}

fn store_path(state: &Path, root: &Path) -> PathBuf {
    let hash = Sha256::digest(root.as_os_str().as_encoded_bytes());
    let key: String = hash.iter().map(|byte| format!("{byte:02x}")).collect();
    state.join("diff-review").join(format!("{key}.json"))
}
fn check_regular(path: &Path) -> Result<bool, String> {
    validate_parent(path)?;
    match fs::symlink_metadata(path) {
        Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => Ok(true),
        Ok(_) => Err("Review store must be a regular file, not a symlink".into()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(e.to_string()),
    }
}
fn validate_parent(path: &Path) -> Result<(), String> {
    let parent = path.parent().ok_or("Review store has no parent")?;
    // Check existing ancestors before creating anything through them.
    for ancestor in parent.ancestors() {
        match fs::symlink_metadata(ancestor) {
            Ok(m) if m.file_type().is_symlink() || !m.is_dir() => {
                return Err("Review storage directory must not be a symlink".into());
            }
            Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.to_string()),
            _ => {}
        }
    }
    Ok(())
}
fn prepare_parent(path: &Path) -> Result<(), String> {
    validate_parent(path)?;
    let parent = path.parent().ok_or("Review store has no parent")?;
    fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
            .map_err(|e| e.to_string())?;
    }
    Ok(())
}
fn read(path: &Path) -> Result<Vec<StoredComment>, String> {
    if !check_regular(path)? {
        return Ok(Vec::new());
    }
    let mut options = fs::OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
    }
    let file = options.open(path).map_err(|e| e.to_string())?;
    if !file.metadata().map_err(|e| e.to_string())?.is_file() {
        return Err("Review store is not a regular file".into());
    }
    let mut bytes = Vec::new();
    file.take(MAX_STORE as u64 + 1)
        .read_to_end(&mut bytes)
        .map_err(|e| e.to_string())?;
    if bytes.len() > MAX_STORE {
        return Err("Review comment store exceeds size limit".into());
    }
    let comments: Vec<StoredComment> =
        serde_json::from_slice(&bytes).map_err(|e| format!("Cannot read review comments: {e}"))?;
    if comments.len() > MAX_COMMENTS {
        return Err("Too many stored review comments".into());
    }
    for stored in &comments {
        git::validate_path(&stored.comment.path)?;
    }
    Ok(comments)
}
fn write(path: &Path, comments: &[StoredComment]) -> Result<(), String> {
    check_regular(path)?;
    let bytes = serde_json::to_vec(comments).map_err(|e| e.to_string())?;
    if bytes.len() > MAX_STORE {
        return Err("Review comment store exceeds size limit".into());
    }
    atomic_write_with_permissions(path, &bytes, Some(0o600)).map_err(|e| e.to_string())
}
fn lock(path: &Path, options: &ReviewReadOptions<'_>) -> Result<CrossProcessFileLock, String> {
    loop {
        options.check()?;
        if let Some(lock) = CrossProcessFileLock::try_acquire(path).map_err(|e| e.to_string())? {
            return Ok(lock);
        }
        std::thread::sleep(options.remaining()?.min(Duration::from_millis(10)));
    }
}
pub(super) fn load_reanchored(
    state: &Path,
    root: &Path,
    files: &mut Vec<ReviewFile>,
    options: &ReviewReadOptions<'_>,
) -> Result<Vec<ReviewComment>, String> {
    options.cancellation.check().map_err(|e| e.to_string())?;
    let path = store_path(state, root);
    if !check_regular(&path)? {
        return Ok(Vec::new());
    }
    let _lock = match prepare_parent(&path).and_then(|()| lock(&path, options)) {
        Ok(lock) => lock,
        Err(error) => {
            options.cancellation.check().map_err(|e| e.to_string())?;
            let mut comments = read(&path)?;
            for stored in &mut comments {
                stored.comment.stale = true;
            }
            files.push(git::notice_file(
                "[saved comments]",
                format!("Using saved anchors: {error}"),
            ));
            return Ok(comments.into_iter().map(|s| s.comment).collect());
        }
    };
    let mut comments = read(&path)?;
    let mut dirty = false;
    let mut budget = MAX_TOTAL_SOURCE.saturating_sub(
        files
            .iter()
            .map(|f| f.original.len() + f.current.len())
            .sum(),
    );
    for stored in &mut comments {
        options.cancellation.check().map_err(|e| e.to_string())?;
        if let Err(error) = options.check() {
            stored.comment.stale = true;
            if !files.iter().any(|f| f.path == stored.comment.path) {
                files.push(git::notice_file(&stored.comment.path, error));
            }
            continue;
        }
        if !files.iter().any(|f| f.path == stored.comment.path) {
            let mut file = git::read_file(
                root,
                &stored.comment.path,
                true,
                (budget / 2).min(MAX_SOURCE),
                options,
            );
            budget = budget.saturating_sub(file.original.len() + file.current.len());
            if file.original.is_empty() && file.current.is_empty() && file.notice.is_none() {
                file.notice = Some("Commented file is no longer present".into());
            }
            files.push(file);
        }
        if let Some(file) = files.iter().find(|f| f.path == stored.comment.path) {
            dirty |= reanchor(stored, file);
        }
    }
    options.cancellation.check().map_err(|e| e.to_string())?;
    if dirty
        && options.check().is_ok()
        && let Err(error) = write(&path, &comments)
    {
        files.push(git::notice_file(
            "[saved comments]",
            format!("Could not persist updated locations: {error}"),
        ));
    }
    Ok(comments.into_iter().map(|s| s.comment).collect())
}
fn reanchor(stored: &mut StoredComment, file: &ReviewFile) -> bool {
    if file.notice.is_some() {
        let changed = !stored.comment.stale;
        stored.comment.stale = true;
        return changed;
    }
    let lines: Vec<_> = source(file, stored.comment.side).lines().collect();
    let previous = (stored.comment.line, stored.comment.stale);
    let matches: Vec<_> = lines
        .iter()
        .enumerate()
        .filter(|(_, line)| **line == stored.comment.anchor)
        .map(|(index, _)| index)
        .collect();
    let contextual: Vec<_> = matches
        .iter()
        .copied()
        .filter(|&i| {
            stored.before.as_deref() == i.checked_sub(1).and_then(|n| lines.get(n).copied())
                && stored.after.as_deref() == lines.get(i + 1).copied()
        })
        .collect();
    let candidates = if contextual.is_empty() {
        &matches
    } else {
        &contextual
    };
    let nearest = candidates
        .iter()
        .min_by_key(|&&index| (index + 1).abs_diff(stored.comment.line))
        .copied();
    match nearest {
        Some(index) => {
            stored.comment.line = index + 1;
            stored.comment.stale |= candidates.len() != 1 || contextual.is_empty();
        }
        None => {
            stored.comment.line = stored.comment.line.max(1).min(lines.len().max(1));
            stored.comment.stale = true;
        }
    }
    previous != (stored.comment.line, stored.comment.stale)
}
pub(super) fn save(
    state: &Path,
    root: &Path,
    change: CommentChange,
    options: &ReviewReadOptions<'_>,
) -> Result<(), String> {
    options.check()?;
    let path = store_path(state, root);
    prepare_parent(&path)?;
    let _lock = lock(&path, options)?;
    let mut comments = read(&path)?;
    match change {
        CommentChange::Delete(id) => {
            comments.retain(|c| c.comment.id != id);
        }
        CommentChange::Resolve { id, resolved } => {
            comments
                .iter_mut()
                .find(|c| c.comment.id == id)
                .ok_or("Review comment no longer exists")?
                .comment
                .resolved = resolved;
        }
        CommentChange::Save {
            id,
            path,
            side,
            line,
            text,
            anchor,
        } => {
            if text.trim().is_empty() || text.len() > MAX_COMMENT_TEXT {
                return Err("Comment must contain 1–8192 bytes of text".into());
            }
            git::validate_path(&path)?;
            if let Some(id) = id {
                // Editing text must not silently move an existing comment or erase its original anchor.
                comments
                    .iter_mut()
                    .find(|c| c.comment.id == id)
                    .ok_or("Review comment no longer exists")?
                    .comment
                    .text = text;
            } else {
                if comments.len() >= MAX_COMMENTS {
                    return Err("Review comment limit reached (256)".into());
                }
                let anchor = anchor.ok_or("Cannot comment without a snapshot anchor")?;
                if line == 0 || anchor.text.len() > MAX_SOURCE {
                    return Err("Invalid review anchor".into());
                }
                let file = git::read_file(root, &path, true, MAX_SOURCE, options);
                options.check()?;
                let mut stored = StoredComment {
                    comment: ReviewComment {
                        id: uuid::Uuid::new_v4().to_string(),
                        path,
                        side,
                        line,
                        anchor: anchor.text,
                        text,
                        stale: false,
                        resolved: false,
                    },
                    before: anchor.before,
                    after: anchor.after,
                };
                reanchor(&mut stored, &file);
                comments.push(stored);
            }
        }
    }
    options.check()?;
    write(&path, &comments)
}