weavatrix-edit 0.1.4

Deterministic, Unicode-safe edit-plan validation and application for source refactoring
Documentation
use std::collections::{BTreeMap, BTreeSet};

use blazingly_json::Value;

use super::{BorrowedFileEdit, EditValidationStats, too_large};
use crate::{
    error::{EditError, ErrorCode},
    limits::PlanLimits,
    model::TextEdit,
    path::{portable_path_key, validate_plan_path},
};

pub(super) fn validate_file_views<'file, I>(
    files: I,
    limits: PlanLimits,
) -> Result<EditValidationStats, EditError>
where
    I: IntoIterator<Item = BorrowedFileEdit<'file>>,
{
    let mut exact_paths = BTreeSet::new();
    let mut portable_paths = BTreeSet::new();
    let mut total_edits = 0_usize;
    let mut total_text_bytes = 0_usize;
    for (file_index, file) in files.into_iter().enumerate() {
        validate_file(file, file_index, limits)?;
        validate_unique_paths(file, file_index, &mut exact_paths, &mut portable_paths)?;
        total_edits = total_edits
            .checked_add(file.edits.len())
            .ok_or_else(|| too_large("total edit count overflow"))?;
        if total_edits > limits.max_total_edits {
            return Err(too_large(format!(
                "plan contains more than {} total edits",
                limits.max_total_edits
            )));
        }
        total_text_bytes = count_text(file.edits, total_text_bytes, limits)?;
    }
    Ok(EditValidationStats {
        total_edits,
        total_text_bytes,
    })
}

pub(crate) fn validate_text_edit(edit: &TextEdit, index: usize) -> Result<(), EditError> {
    validate_extension_keys(
        &edit.extensions,
        &[
            "startLine",
            "startChar",
            "endLine",
            "endChar",
            "before",
            "after",
            "provenance",
        ],
        ErrorCode::InvalidEdit,
    )
    .map_err(|error| error.at_edit(index))?;
    let range = edit.range();
    if range.start.line == 0 || range.end.line == 0 {
        return Err(invalid_edit("lines are 1-based", index));
    }
    if range.end < range.start {
        return Err(invalid_edit("edit end precedes its start", index));
    }
    if edit.before == edit.after {
        return Err(invalid_edit("before and after are identical", index));
    }
    if !edit.provenance.is_applicable() {
        return Err(
            EditError::new(ErrorCode::UnprovenEdit, "edit provenance is not applicable")
                .at_edit(index),
        );
    }
    Ok(())
}

fn validate_file(
    file: BorrowedFileEdit<'_>,
    file_index: usize,
    limits: PlanLimits,
) -> Result<(), EditError> {
    validate_extension_keys(
        file.extensions,
        file.reserved_extension_keys,
        ErrorCode::InvalidFile,
    )
    .map_err(|error| error.at_file(file_index))?;
    validate_plan_path(file.path, limits.max_path_bytes)
        .map_err(|error| error.at_file(file_index))?;
    if !valid_sha256(file.sha256) {
        return Err(EditError::new(
            ErrorCode::InvalidFile,
            "sha256 must be 64 lowercase hexadecimal characters",
        )
        .at_file(file_index));
    }
    if file.edits.is_empty() {
        return Err(
            EditError::new(ErrorCode::InvalidFile, "file edits must be non-empty")
                .at_file(file_index),
        );
    }
    if file.edits.len() > limits.max_edits_per_file {
        return Err(too_large(format!(
            "{} contains more than {} edits",
            file.path, limits.max_edits_per_file
        ))
        .at_file(file_index));
    }
    for (edit_index, edit) in file.edits.iter().enumerate() {
        validate_text_edit(edit, edit_index).map_err(|error| error.at_file(file_index))?;
    }
    Ok(())
}

fn validate_unique_paths<'a>(
    file: BorrowedFileEdit<'a>,
    file_index: usize,
    exact_paths: &mut BTreeSet<&'a str>,
    portable_paths: &mut BTreeSet<String>,
) -> Result<(), EditError> {
    if !exact_paths.insert(file.path) {
        return Err(EditError::new(
            ErrorCode::InvalidPlan,
            format!("duplicate file entry: {}", file.path),
        )
        .at_file(file_index));
    }
    if !portable_paths.insert(portable_path_key(file.path)) {
        return Err(EditError::new(
            ErrorCode::InvalidPlan,
            format!(
                "file path aliases another entry on a portable worktree: {}",
                file.path
            ),
        )
        .at_file(file_index));
    }
    Ok(())
}

fn count_text(
    edits: &[TextEdit],
    mut total: usize,
    limits: PlanLimits,
) -> Result<usize, EditError> {
    for edit in edits {
        total = total
            .checked_add(edit.before.len())
            .and_then(|size| size.checked_add(edit.after.len()))
            .ok_or_else(|| too_large("total edit text size overflow"))?;
        if total > limits.max_total_text_bytes {
            return Err(too_large(format!(
                "plan edit text exceeds the {}-byte limit",
                limits.max_total_text_bytes
            )));
        }
    }
    Ok(total)
}

fn valid_sha256(value: &str) -> bool {
    value.len() == 64
        && value
            .bytes()
            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}

fn invalid_edit(message: impl Into<String>, index: usize) -> EditError {
    EditError::new(ErrorCode::InvalidEdit, message).at_edit(index)
}

pub(super) fn validate_extension_keys(
    extensions: &BTreeMap<String, Value>,
    reserved: &[&str],
    code: ErrorCode,
) -> Result<(), EditError> {
    if let Some(key) = extensions
        .keys()
        .find(|key| reserved.contains(&key.as_str()))
    {
        return Err(EditError::new(
            code,
            format!("extension field {key:?} collides with a reserved field"),
        ));
    }
    Ok(())
}