weavatrix-refactor-plan 0.1.0

Evidence metadata, validation profiles, and canonical fingerprints for Weavatrix refactor plans
Documentation
use crate::{
    BorrowedFileEdit, FileEdit, PlanError, PlanErrorCode, PlanStats, RefactorOperation,
    RefactorPlan, RefactorPlanLimits, portable_path_key, validate_plan_path,
};
use std::collections::BTreeMap;

mod edit_validation;
mod overlap;

const RENAME_RESERVED_EXTENSION_KEYS: &[&str] = &["from", "to", "expectedSourceSha256", "edits"];

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum EndpointKind {
    Modify,
    Create,
    Delete,
    Rename,
}

#[derive(Clone, Copy, Debug)]
struct Endpoint {
    index: usize,
    kind: EndpointKind,
}

/// Everything known about one portable path key: the first spelling that
/// registered it plus its (unique) consuming and producing operations.
#[derive(Debug)]
struct PathSlot<'plan> {
    original: &'plan str,
    input: Option<Endpoint>,
    output: Option<Endpoint>,
}

pub(crate) fn validate_structure(
    plan: &RefactorPlan,
    limits: RefactorPlanLimits,
) -> Result<PlanStats, PlanError> {
    validate_envelope(plan, limits)?;
    crate::extension::validate_extensions(plan, limits)?;
    let mut validator = StructureValidator::new(limits, plan.operations.len(), &plan.operation);
    for (index, operation) in plan.operations.iter().enumerate() {
        validator.visit(operation, index)?;
    }
    validator.finish()
}

fn validate_envelope(plan: &RefactorPlan, limits: RefactorPlanLimits) -> Result<(), PlanError> {
    limits.validate()?;
    if plan.schema_version != crate::REFACTOR_PLAN_SCHEMA {
        return Err(PlanError::new(
            PlanErrorCode::SchemaMismatch,
            format!("schemaVersion must be {}", crate::REFACTOR_PLAN_SCHEMA),
        )
        .at_field("schemaVersion"));
    }
    if plan.operation.is_empty() || plan.operations.is_empty() {
        return Err(invalid("operation and operations must be non-empty"));
    }
    if plan.operation.len() > limits.max_operation_bytes {
        return Err(too_large("operation exceeds its UTF-8 byte limit").at_field("operation"));
    }
    if plan.operations.len() > limits.max_operations {
        return Err(too_large("plan contains too many logical operations"));
    }
    Ok(())
}

struct StructureValidator<'plan> {
    limits: RefactorPlanLimits,
    operation: &'plan str,
    paths: BTreeMap<String, PathSlot<'plan>>,
    edit_files: Vec<BorrowedFileEdit<'plan>>,
    edit_operation_indices: Vec<usize>,
    create_bytes: usize,
    operation_count: usize,
}

impl<'plan> StructureValidator<'plan> {
    fn new(limits: RefactorPlanLimits, operation_count: usize, operation: &'plan str) -> Self {
        Self {
            limits,
            operation,
            paths: BTreeMap::new(),
            edit_files: Vec::with_capacity(operation_count),
            edit_operation_indices: Vec::with_capacity(operation_count),
            create_bytes: 0,
            operation_count,
        }
    }

    fn visit(
        &mut self,
        operation: &'plan RefactorOperation,
        index: usize,
    ) -> Result<(), PlanError> {
        match operation {
            RefactorOperation::Modify(file) => self.modify(file, index),
            RefactorOperation::Create(file) => self.create(file, index),
            RefactorOperation::Delete(file) => self.delete(file, index),
            RefactorOperation::Rename(file) => self.rename(file, index),
        }
    }

    fn modify(&mut self, file: &'plan FileEdit, index: usize) -> Result<(), PlanError> {
        overlap::validate_coordinate_overlaps(&file.edits, index, &file.path)?;
        let slot = self.register_path(&file.path, index)?;
        set_endpoint(&mut slot.input, EndpointKind::Modify, index, "consume")?;
        set_endpoint(&mut slot.output, EndpointKind::Modify, index, "produce")?;
        self.edit_files.push(BorrowedFileEdit::from(file));
        self.edit_operation_indices.push(index);
        Ok(())
    }

    fn create(&mut self, file: &'plan crate::CreateFile, index: usize) -> Result<(), PlanError> {
        if file.contents.len() > self.limits.max_create_bytes_per_file {
            return Err(too_large("created file exceeds its byte limit")
                .at_operation(index)
                .at_path(&file.path));
        }
        self.create_bytes = self
            .create_bytes
            .checked_add(file.contents.len())
            .ok_or_else(|| too_large("created content byte total overflow"))?;
        if self.create_bytes > self.limits.max_total_create_bytes {
            return Err(too_large("created content exceeds the total byte limit"));
        }
        let slot = self.register_path(&file.path, index)?;
        set_endpoint(&mut slot.output, EndpointKind::Create, index, "produce")
    }

    fn delete(&mut self, file: &'plan crate::DeleteFile, index: usize) -> Result<(), PlanError> {
        validate_hash(&file.expected_sha256, &file.path, index)?;
        let slot = self.register_path(&file.path, index)?;
        set_endpoint(&mut slot.input, EndpointKind::Delete, index, "consume")
    }

    fn rename(&mut self, file: &'plan crate::RenameFile, index: usize) -> Result<(), PlanError> {
        overlap::validate_coordinate_overlaps(&file.edits, index, &file.from)?;
        validate_hash(&file.expected_source_sha256, &file.from, index)?;
        let from = self.register_path_key(&file.from, index)?;
        let to = self.register_path_key(&file.to, index)?;
        if from == to {
            return Err(conflict(
                "rename source and destination alias",
                index,
                &file.from,
            ));
        }
        let source = self.paths.get_mut(&from).expect("registered rename source");
        set_endpoint(&mut source.input, EndpointKind::Rename, index, "consume")?;
        let target = self.paths.get_mut(&to).expect("registered rename target");
        set_endpoint(&mut target.output, EndpointKind::Rename, index, "produce")?;
        if !file.edits.is_empty() {
            self.edit_files.push(BorrowedFileEdit {
                path: &file.from,
                sha256: &file.expected_source_sha256,
                edits: &file.edits,
                extensions: &file.extensions,
                reserved_extension_keys: RENAME_RESERVED_EXTENSION_KEYS,
            });
            self.edit_operation_indices.push(index);
        }
        Ok(())
    }

    /// Validates the path and returns its slot with one map operation.
    fn register_path(
        &mut self,
        path: &'plan str,
        index: usize,
    ) -> Result<&mut PathSlot<'plan>, PlanError> {
        validate_plan_path(path, self.limits.max_path_bytes)
            .map_err(|error| error.at_operation(index))?;
        let key = portable_path_key(path);
        let slot = self.paths.entry(key).or_insert_with(|| PathSlot {
            original: path,
            input: None,
            output: None,
        });
        if slot.original != path {
            return Err(conflict("operation paths alias portably", index, path));
        }
        Ok(slot)
    }

    /// Rename endpoints also need the key itself for the self-alias check.
    fn register_path_key(&mut self, path: &'plan str, index: usize) -> Result<String, PlanError> {
        validate_plan_path(path, self.limits.max_path_bytes)
            .map_err(|error| error.at_operation(index))?;
        let key = portable_path_key(path);
        let slot = self.paths.entry(key.clone()).or_insert_with(|| PathSlot {
            original: path,
            input: None,
            output: None,
        });
        if slot.original != path {
            return Err(conflict("operation paths alias portably", index, path));
        }
        Ok(key)
    }

    fn finish(self) -> Result<PlanStats, PlanError> {
        if self.paths.len() > self.limits.max_paths {
            return Err(too_large(
                "plan touches more paths than the configured limit",
            ));
        }
        validate_cross_roles(&self.paths)?;
        let (edits, edit_text_bytes) = edit_validation::validate_edits(
            self.operation,
            &self.edit_files,
            &self.edit_operation_indices,
            self.limits,
        )?;
        Ok(PlanStats {
            operations: self.operation_count,
            paths: self.paths.len(),
            edits,
            edit_text_bytes,
            create_bytes: self.create_bytes,
        })
    }
}

fn set_endpoint(
    endpoint: &mut Option<Endpoint>,
    kind: EndpointKind,
    index: usize,
    verb: &str,
) -> Result<(), PlanError> {
    if endpoint.replace(Endpoint { index, kind }).is_some() {
        return Err(PlanError::new(
            PlanErrorCode::OperationConflict,
            format!("multiple operations {verb} the same path"),
        )
        .at_operation(index));
    }
    Ok(())
}

fn validate_cross_roles(paths: &BTreeMap<String, PathSlot<'_>>) -> Result<(), PlanError> {
    for slot in paths.values() {
        let (Some(input), Some(output)) = (slot.input, slot.output) else {
            continue;
        };
        let allowed = (input.kind == EndpointKind::Modify
            && output.kind == EndpointKind::Modify
            && input.index == output.index)
            || (input.kind == EndpointKind::Rename && output.kind == EndpointKind::Rename);
        if !allowed {
            return Err(conflict(
                "operation overlaps another operation",
                input.index.max(output.index),
                slot.original,
            ));
        }
    }
    Ok(())
}

fn validate_hash(value: &str, path: &str, index: usize) -> Result<(), PlanError> {
    if value.len() != 64
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
    {
        return Err(
            invalid("expected SHA-256 must be 64 lowercase hexadecimal characters")
                .at_operation(index)
                .at_path(path),
        );
    }
    Ok(())
}

fn conflict(message: &str, index: usize, path: &str) -> PlanError {
    PlanError::new(PlanErrorCode::OperationConflict, message)
        .at_operation(index)
        .at_path(path)
}

fn invalid(message: impl Into<String>) -> PlanError {
    PlanError::new(PlanErrorCode::InvalidPlan, message)
}

fn too_large(message: impl Into<String>) -> PlanError {
    PlanError::new(PlanErrorCode::PlanTooLarge, message)
}