weavatrix-worktree 0.2.0

Bounded, crash-recoverable multi-file worktree edits for source refactoring
Documentation
use weavatrix_refactor_plan::{
    PlanError, PlanErrorCode, RefactorPlanLimits, ValidatedExecutorPlan, validate_executor_plan,
};

use crate::{
    WorktreePlan,
    error::{TransactionPhase, WorktreeError, WorktreeErrorCode},
    options::WorktreeOptions,
};

pub(super) fn validate(
    plan: &WorktreePlan,
    options: WorktreeOptions,
) -> Result<ValidatedExecutorPlan<'_>, WorktreeError> {
    validate_executor_plan(plan, plan_limits(options)).map_err(|error| {
        let path = error.path().map(str::to_owned);
        let operation_index = error.operation_index();
        let mut mapped = WorktreeError::with_source(
            worktree_code(&error),
            TransactionPhase::Validate,
            "weavatrix-refactor-plan rejected the executable plan",
            error,
        );
        if let Some(path) = path {
            mapped = mapped.at_path(path);
        }
        if let Some(index) = operation_index {
            mapped = mapped.at_file(index);
        }
        mapped
    })
}

fn worktree_code(error: &PlanError) -> WorktreeErrorCode {
    match error.code() {
        PlanErrorCode::OperationConflict => WorktreeErrorCode::OperationConflict,
        PlanErrorCode::UnsafePath if error.path().is_some_and(is_reserved_path) => {
            WorktreeErrorCode::ReservedPath
        }
        PlanErrorCode::UnsafePath => WorktreeErrorCode::PathEscape,
        _ => WorktreeErrorCode::InvalidPlan,
    }
}

fn is_reserved_path(path: &str) -> bool {
    path.split('/').next().is_some_and(|root| {
        root.eq_ignore_ascii_case(".git") || root.eq_ignore_ascii_case(".weavatrix")
    })
}

fn plan_limits(options: WorktreeOptions) -> RefactorPlanLimits {
    let limits = options.limits;
    RefactorPlanLimits {
        max_operations: limits.max_files,
        max_paths: limits.max_files,
        max_path_bytes: 4_096,
        max_operation_bytes: limits.max_operation_bytes,
        max_edits_per_file: limits.max_edits_per_file,
        max_total_edits: limits.max_files.saturating_mul(limits.max_edits_per_file),
        max_create_bytes_per_file: limits.max_output_bytes_per_file,
        max_total_create_bytes: limits.max_total_output_bytes,
        max_total_text_bytes: limits.max_total_artifact_bytes,
        max_extension_bytes: limits.max_extension_bytes,
        max_extension_nodes: limits.max_extension_nodes,
        max_extension_depth: limits.max_extension_depth,
        max_evidence_entries: limits.max_evidence_entries,
        max_evidence_text_bytes: limits.max_evidence_text_bytes,
        max_code_bytes: limits.max_code_bytes,
    }
}