weavatrix-worktree 0.2.1

Bounded, crash-recoverable multi-file worktree edits for source refactoring
Documentation
use weavatrix_refactor_plan::EditPlan;

use crate::{
    error::{TransactionPhase, WorktreeError, WorktreeErrorCode},
    filesystem::FsRoot,
    hash::serialized_hash,
    journal::{FinishOutcome, JournalRecord, JournalWriter},
    options::WorktreeOptions,
};

use super::{
    PreparedTransaction,
    lock::acquire,
    plan::{dry_run_report, project_plan},
    stage::stage_all,
    util::journal_error,
};

const HEX: &[u8; 16] = b"0123456789abcdef";

pub(crate) fn prepare_transaction(
    root: &FsRoot,
    options: WorktreeOptions,
    plan: &EditPlan,
) -> Result<PreparedTransaction, WorktreeError> {
    let locked = acquire(root)?;
    if locked
        .control
        .open_journal()
        .map_err(open_journal_error)?
        .is_some()
        || locked
            .control
            .open_operation_journal()
            .map_err(open_journal_error)?
            .is_some()
        || locked
            .control
            .open_undo_journal()
            .map_err(open_journal_error)?
            .is_some()
    {
        return Err(WorktreeError::new(
            WorktreeErrorCode::RecoveryRequired,
            TransactionPhase::Lock,
            "a previous transaction journal must be recovered first",
        )
        .requiring_recovery());
    }
    let projected = project_plan(root, options, plan)?;
    let preview = dry_run_report(plan, &projected);
    let transaction_id = random_id()?;
    let journal_file = locked.control.create_journal().map_err(|error| {
        WorktreeError::with_source(
            WorktreeErrorCode::Io,
            TransactionPhase::Prepare,
            "failed to create an exclusive transaction journal",
            error,
        )
    })?;
    locked.control.sync().map_err(|error| {
        WorktreeError::with_source(
            WorktreeErrorCode::DurabilityFailed,
            TransactionPhase::Prepare,
            "failed to synchronize the journal directory",
            error,
        )
    })?;
    let mut journal = JournalWriter::new(journal_file, options.limits.max_journal_bytes as u64)
        .map_err(|error| journal_error(TransactionPhase::Prepare, "invalid new journal", error))?;
    append_header(&mut journal, plan, &transaction_id)
        .map_err(|error| error.in_transaction(transaction_id.clone()))?;
    let staged = match stage_all(projected, &transaction_id, options, &mut journal) {
        Ok(staged) => staged,
        Err(error) if error.recovery_required() => return Err(error),
        Err(error) => {
            finish_aborted(&mut journal, &locked.control)?;
            return Err(error);
        }
    };
    journal
        .append(&JournalRecord::Prepared {
            file_count: u32::try_from(staged.len()).map_err(|_| {
                WorktreeError::new(
                    WorktreeErrorCode::TransactionTooLarge,
                    TransactionPhase::Prepare,
                    "file count does not fit the journal contract",
                )
            })?,
        })
        .map_err(|error| {
            journal_error(
                TransactionPhase::Prepare,
                "failed to record durable preparation",
                error,
            )
            .requiring_recovery()
        })?;
    Ok(PreparedTransaction {
        transaction_id,
        operation: plan.operation.clone(),
        preview,
        files: staged,
        options,
        journal,
        control: locked.control,
        _lock: locked.file,
    })
}

fn append_header(
    journal: &mut JournalWriter,
    plan: &EditPlan,
    transaction_id: &str,
) -> Result<(), WorktreeError> {
    let contract_hash = serialized_hash(plan).map_err(|error| {
        WorktreeError::with_source(
            WorktreeErrorCode::InvalidPlan,
            TransactionPhase::Prepare,
            "failed to encode the validated plan contract",
            error,
        )
    })?;
    journal
        .append(&JournalRecord::Header {
            transaction_id: transaction_id.to_owned(),
            contract_hash: contract_hash.to_string(),
            file_count: u32::try_from(plan.files.len()).map_err(|_| {
                WorktreeError::new(
                    WorktreeErrorCode::TransactionTooLarge,
                    TransactionPhase::Prepare,
                    "file count does not fit the journal contract",
                )
            })?,
        })
        .map_err(|error| {
            journal_error(
                TransactionPhase::Prepare,
                "failed to synchronize the journal header",
                error,
            )
            .requiring_recovery()
        })?;
    Ok(())
}

fn finish_aborted(
    journal: &mut JournalWriter,
    control: &crate::filesystem::ControlDir,
) -> Result<(), WorktreeError> {
    journal
        .append(&JournalRecord::Finished {
            outcome: FinishOutcome::Aborted,
        })
        .map_err(|error| {
            journal_error(
                TransactionPhase::Cleanup,
                "failed to record aborted preparation",
                error,
            )
            .requiring_recovery()
        })?;
    control.remove_journal().map_err(|error| {
        WorktreeError::with_source(
            WorktreeErrorCode::RecoveryRequired,
            TransactionPhase::Cleanup,
            "failed to remove the aborted journal",
            error,
        )
        .requiring_recovery()
    })
}

fn random_id() -> Result<String, WorktreeError> {
    let mut bytes = [0_u8; 16];
    getrandom::fill(&mut bytes).map_err(|error| {
        WorktreeError::with_source(
            WorktreeErrorCode::Io,
            TransactionPhase::Prepare,
            "failed to generate a transaction identifier",
            error,
        )
    })?;
    Ok(bytes
        .iter()
        .fold(String::with_capacity(32), |mut id, byte| {
            id.push(char::from(HEX[(byte >> 4) as usize]));
            id.push(char::from(HEX[(byte & 15) as usize]));
            id
        }))
}

fn open_journal_error(error: std::io::Error) -> WorktreeError {
    let code = if error.kind() == std::io::ErrorKind::InvalidData {
        WorktreeErrorCode::JournalCorrupt
    } else {
        WorktreeErrorCode::Io
    };
    let mapped = WorktreeError::with_source(
        code,
        TransactionPhase::Lock,
        "failed to inspect pending recovery state",
        error,
    );
    if code == WorktreeErrorCode::JournalCorrupt {
        mapped.requiring_recovery()
    } else {
        mapped
    }
}