prikk-store 0.23.0

Prikk storage crate scaffold.
Documentation
//! Non-mutating rollback preview for the supported patch subset.
//!
//! PR-027 keeps rollback behavior read-only. It combines the supported inverse-plan validation
//! with the supported patch replay result, then compares the current replayed state with the latest
//! snapshot baseline. It does not publish rollback refs, write inverse patches, or modify the
//! worktree.

use std::collections::BTreeMap;

use prikk_error::{PrikkError, Result};
use prikk_object::ObjectId;

use crate::layout::RepositoryLayout;
use crate::patch_inverse::prepare_patch_inverse_plan;
use crate::patch_replay::{ReplayManifest, replay_supported_patch_chain};
use crate::snapshot::SnapshotManifest;

/// Read-only preview of applying the supported inverse plan back to the latest snapshot baseline.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RollbackPreviewPlan {
    /// Ref used as the rollback-preview target.
    pub ref_name: String,
    /// Current target block ID whose supported chain was validated.
    pub target_block_id: ObjectId,
    /// Number of blocks validated from the latest snapshot baseline to the target.
    pub block_count: usize,
    /// Number of patch objects validated.
    pub patch_count: usize,
    /// Number of inverse operations generated by the supported inverse planner.
    pub inverse_operation_count: usize,
    /// Deterministic ID hint for the unsigned inverse Patch payload.
    pub inverse_patch_id_hint: ObjectId,
    /// Number of files in the current replayed target state.
    pub current_file_count: usize,
    /// Total bytes in the current replayed target state.
    pub current_content_bytes: u64,
    /// Number of files in the preview target state.
    pub preview_file_count: usize,
    /// Total bytes in the preview target state.
    pub preview_content_bytes: u64,
    /// Number of file-level differences between current state and preview state.
    pub change_count: usize,
    /// Number of files rollback would create or restore.
    pub would_create_files: usize,
    /// Number of files rollback would delete.
    pub would_delete_files: usize,
    /// Number of files rollback would replace.
    pub would_replace_files: usize,
    /// File-level differences in deterministic path order.
    pub changes: Vec<RollbackPreviewChange>,
}

/// One file-level change that rollback would make if later authorized and materialized.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RollbackPreviewChange {
    /// Repository-relative path.
    pub path: String,
    /// Preview change kind.
    pub kind: RollbackPreviewChangeKind,
    /// Current content byte length, when the file currently exists.
    pub current_bytes: Option<u64>,
    /// Preview content byte length, when the file would exist after rollback.
    pub preview_bytes: Option<u64>,
}

/// File-level rollback preview change kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RollbackPreviewChangeKind {
    /// Rollback would restore a file that is absent in the current replayed state.
    WouldCreate,
    /// Rollback would remove a file that exists in the current replayed state.
    WouldDelete,
    /// Rollback would replace current bytes with previous bytes.
    WouldReplace,
}

impl RollbackPreviewChangeKind {
    /// Return a stable CLI label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::WouldCreate => "would-create",
            Self::WouldDelete => "would-delete",
            Self::WouldReplace => "would-replace",
        }
    }
}

/// Prepare a non-mutating rollback preview for the supported patch-operation subset.
///
/// The preview target is the latest snapshot baseline inside the supported single-parent replay
/// window. Refs, objects, WAL files, and worktree files are not modified.
pub fn prepare_rollback_preview(
    layout: &RepositoryLayout,
    ref_name: &str,
) -> Result<RollbackPreviewPlan> {
    let inverse = prepare_patch_inverse_plan(layout, ref_name)?;
    let replay = replay_supported_patch_chain(layout, ref_name)?;
    if inverse.target_block_id != replay.target_block_id {
        return Err(PrikkError::Integrity(format!(
            "inverse target {} does not match replay target {}",
            inverse.target_block_id, replay.target_block_id
        )));
    }

    let current = replay_manifest_to_map(&replay.manifest);
    let preview = manifest_to_map(&replay.baseline_manifest);
    let changes = compare_maps(&current, &preview);
    let would_create_files = changes
        .iter()
        .filter(|change| change.kind == RollbackPreviewChangeKind::WouldCreate)
        .count();
    let would_delete_files = changes
        .iter()
        .filter(|change| change.kind == RollbackPreviewChangeKind::WouldDelete)
        .count();
    let would_replace_files = changes
        .iter()
        .filter(|change| change.kind == RollbackPreviewChangeKind::WouldReplace)
        .count();

    Ok(RollbackPreviewPlan {
        ref_name: ref_name.to_string(),
        target_block_id: replay.target_block_id,
        block_count: inverse.block_count,
        patch_count: inverse.patch_count,
        inverse_operation_count: inverse.inverse_operation_count,
        inverse_patch_id_hint: inverse.inverse_patch_id_hint,
        current_file_count: replay.manifest.files.len(),
        current_content_bytes: replay.manifest.total_content_bytes(),
        preview_file_count: replay.baseline_manifest.files.len(),
        preview_content_bytes: replay.baseline_manifest.total_content_bytes(),
        change_count: changes.len(),
        would_create_files,
        would_delete_files,
        would_replace_files,
        changes,
    })
}

fn manifest_to_map(manifest: &SnapshotManifest) -> BTreeMap<String, Vec<u8>> {
    let mut files = BTreeMap::new();
    for entry in &manifest.files {
        files.insert(entry.path.as_str().to_string(), entry.bytes.clone());
    }
    files
}

/// Content-only view of a mode-aware replay manifest — this comparison is bytes-in/bytes-out, the
/// same as `manifest_to_map`; mode does not participate in "would this file change" preview logic.
fn replay_manifest_to_map(manifest: &ReplayManifest) -> BTreeMap<String, Vec<u8>> {
    let mut files = BTreeMap::new();
    for entry in &manifest.files {
        files.insert(entry.path.as_str().to_string(), entry.bytes.clone());
    }
    files
}

fn compare_maps(
    current: &BTreeMap<String, Vec<u8>>,
    preview: &BTreeMap<String, Vec<u8>>,
) -> Vec<RollbackPreviewChange> {
    let mut changes = Vec::new();
    for (path, current_bytes) in current {
        match preview.get(path) {
            Some(preview_bytes) if preview_bytes == current_bytes => {}
            Some(preview_bytes) => changes.push(RollbackPreviewChange {
                path: path.clone(),
                kind: RollbackPreviewChangeKind::WouldReplace,
                current_bytes: Some(current_bytes.len() as u64),
                preview_bytes: Some(preview_bytes.len() as u64),
            }),
            None => changes.push(RollbackPreviewChange {
                path: path.clone(),
                kind: RollbackPreviewChangeKind::WouldDelete,
                current_bytes: Some(current_bytes.len() as u64),
                preview_bytes: None,
            }),
        }
    }
    for (path, preview_bytes) in preview {
        if current.contains_key(path) {
            continue;
        }
        changes.push(RollbackPreviewChange {
            path: path.clone(),
            kind: RollbackPreviewChangeKind::WouldCreate,
            current_bytes: None,
            preview_bytes: Some(preview_bytes.len() as u64),
        });
    }
    changes.sort_by(|left, right| left.path.cmp(&right.path));
    changes
}

#[cfg(test)]
mod tests;