weavatrix-worktree 0.2.0

Bounded, crash-recoverable multi-file worktree edits for source refactoring
Documentation
use crate::{
    error::{TransactionPhase, WorktreeError, WorktreeErrorCode},
    filesystem::SlotEvidence,
    journal::FinishOutcome,
};

use super::{PreparedWorktreeTransaction, StagedPath};
use crate::operation::{journal::Record, operation_journal_error, stage::cleanup};

use super::evidence::present;

pub(super) fn rollback_changed(
    transaction: &mut PreparedWorktreeTransaction,
    changed: &[usize],
) -> Result<(), WorktreeError> {
    for &index in changed.iter().rev() {
        let path = &transaction.paths[index];
        transaction
            .journal
            .append(&Record::RollbackIntent {
                index: path.stable_index,
            })
            .map_err(|error| {
                operation_journal_error(
                    TransactionPhase::Rollback,
                    "failed to synchronize operation rollback intent",
                    error,
                )
                .requiring_recovery()
            })?;
        restore(path, transaction).map_err(|error| {
            error
                .in_transaction(transaction.transaction_id.clone())
                .requiring_recovery()
        })?;
        transaction
            .journal
            .append(&Record::RolledBack {
                index: path.stable_index,
            })
            .map_err(|error| {
                operation_journal_error(
                    TransactionPhase::Rollback,
                    "restored path but rollback record did not synchronize",
                    error,
                )
                .requiring_recovery()
            })?;
    }
    transaction
        .journal
        .append(&Record::Finished {
            outcome: FinishOutcome::RolledBack,
        })
        .map_err(|error| {
            operation_journal_error(
                TransactionPhase::Rollback,
                "failed to record completed operation rollback",
                error,
            )
            .requiring_recovery()
        })?;
    cleanup(&transaction.paths)?;
    transaction
        .control
        .remove_operation_journal()
        .map_err(|error| {
            WorktreeError::with_source(
                WorktreeErrorCode::RecoveryRequired,
                TransactionPhase::Cleanup,
                "rollback succeeded but operation journal cleanup failed",
                error,
            )
            .requiring_recovery()
        })
}

fn restore(
    path: &StagedPath,
    transaction: &PreparedWorktreeTransaction,
) -> Result<(), WorktreeError> {
    match path.before {
        SlotEvidence::Absent => {
            if let Some(stage) = &path.stage_name
                && path.access.same_file_as_artifact(stage).unwrap_or(false)
            {
                path.access
                    .rollback_linked_install(stage)
                    .map_err(|error| {
                        rollback_error(path, "failed to reverse linked install", error)
                    })?;
            } else {
                path.access
                    .remove_exact(
                        present(path.after).ok_or_else(|| {
                            rollback_logic(path, "absent source has no present output")
                        })?,
                        transaction.options.limits.max_output_bytes_per_file,
                    )
                    .map_err(|error| {
                        rollback_error(path, "failed to remove committed output", error)
                    })?;
            }
            path.access
                .sync_parent()
                .map_err(|error| rollback_error(path, "rollback did not synchronize", error))
        }
        SlotEvidence::Present(before) => {
            let backup = required(path.backup_name.as_deref(), path)?;
            match path
                .access
                .slot_evidence(transaction.options.limits.max_output_bytes_per_file)
            {
                Ok(SlotEvidence::Absent) => {
                    path.access.install_absent_from(backup).map_err(|error| {
                        rollback_error(path, "failed to reinstall exact backup", error)
                    })
                }
                Ok(SlotEvidence::Present(actual)) if Some(actual) == present(path.after) => path
                    .access
                    .replace_from(backup)
                    .map_err(|error| rollback_error(path, "failed to restore exact backup", error)),
                Ok(_) => Err(rollback_logic(
                    path,
                    "refusing to overwrite a foreign path state",
                )),
                Err(error) => Err(rollback_error(
                    path,
                    "failed to inspect rollback target",
                    error,
                )),
            }?;
            path.access
                .sync_parent()
                .map_err(|error| rollback_error(path, "rollback did not synchronize", error))?;
            let mut restored = before;
            restored.identity = path
                .backup
                .ok_or_else(|| rollback_logic(path, "backup evidence is missing"))?
                .identity;
            path.access
                .verify_slot(
                    SlotEvidence::Present(restored),
                    transaction.options.limits.max_source_bytes_per_file,
                )
                .map(drop)
                .map_err(|error| rollback_error(path, "restored backup failed verification", error))
        }
    }
}

pub(super) fn finish_without_changes(
    transaction: &mut PreparedWorktreeTransaction,
) -> Result<(), WorktreeError> {
    transaction
        .journal
        .append(&Record::Finished {
            outcome: FinishOutcome::Aborted,
        })
        .map_err(|error| {
            operation_journal_error(
                TransactionPhase::Cleanup,
                "failed to record unchanged operation abort",
                error,
            )
            .requiring_recovery()
        })?;
    cleanup(&transaction.paths)?;
    transaction
        .control
        .remove_operation_journal()
        .map_err(|error| {
            WorktreeError::with_source(
                WorktreeErrorCode::RecoveryRequired,
                TransactionPhase::Cleanup,
                "failed to remove unchanged operation journal",
                error,
            )
            .requiring_recovery()
        })
}

pub(super) fn required<'a>(
    value: Option<&'a str>,
    path: &StagedPath,
) -> Result<&'a str, WorktreeError> {
    value.ok_or_else(|| rollback_logic(path, "required transaction artifact is missing"))
}

fn rollback_error(path: &StagedPath, message: &str, source: std::io::Error) -> WorktreeError {
    WorktreeError::with_source(
        WorktreeErrorCode::RollbackFailed,
        TransactionPhase::Rollback,
        message,
        source,
    )
    .at_path(path.path.clone())
    .at_file(path.stable_index as usize)
}

fn rollback_logic(path: &StagedPath, message: &str) -> WorktreeError {
    WorktreeError::new(
        WorktreeErrorCode::RollbackFailed,
        TransactionPhase::Rollback,
        message,
    )
    .at_path(path.path.clone())
    .at_file(path.stable_index as usize)
}