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,
}
#[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(())
}
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)
}
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)
}