prikk-store 0.18.2

Prikk storage crate scaffold.
Documentation
//! Merge-evidence store boundary tests.

mod merge_plan;

use prikk_error::Result;
use prikk_object::{
    BlobKind, BlobPayload, BlockKind, CanonicalEncode, ChangePerm, CreateFile, DeleteNode,
    DeleteNodePreimage, NodeId, NodeKind, ObjectEnvelope, ObjectId, ObjectType, Operation,
    OperationKind, PatchPayload, PatchPurpose,
};

use super::{MergeEvidenceTarget, prepare_merge_evidence};
use crate::test_support::{
    dummy_signature, maintainer_signature, signed_block, signed_ref_state_envelope,
    signed_ref_update_envelope, unique_temp_dir,
};
use crate::{FileObjectStore, ObjectWriter, RefPublication, RefStore, RepositoryLayout};

#[test]
fn block_targets_report_confluent_for_independent_create_sequences() -> Result<()> {
    let root = unique_temp_dir("merge-evidence-block-targets");
    let layout = RepositoryLayout::init(root.clone())?;
    let baseline = write_block(&layout, BlockKind::Root, Vec::new(), Vec::new())?;
    let left = write_create_block(&layout, BlockKind::Normal, vec![baseline], "left.txt", 0x21)?;
    let right = write_create_block(
        &layout,
        BlockKind::Normal,
        vec![baseline],
        "right.txt",
        0x22,
    )?;

    let report = prepare_merge_evidence(
        &layout,
        baseline,
        MergeEvidenceTarget::Block(left),
        MergeEvidenceTarget::Block(right),
    )?;

    assert_eq!(report.outcome, "Confluent");
    assert_eq!(report.reason, Some("proven_confluent"));
    assert_eq!(report.left_operation_count, 1);
    assert_eq!(report.right_operation_count, 1);
    assert_eq!(report.displayed_item_count(), report.total_item_count());
    assert_eq!(report.left_selector.target_block_id, left);
    assert_eq!(report.right_selector.target_block_id, right);
    let _ = std::fs::remove_dir_all(root);
    Ok(())
}

#[test]
fn ref_target_reports_selector_and_resolved_block() -> Result<()> {
    let root = unique_temp_dir("merge-evidence-ref-target");
    let layout = RepositoryLayout::init(root.clone())?;
    let baseline = write_block(&layout, BlockKind::Root, Vec::new(), Vec::new())?;
    let left = write_create_block(&layout, BlockKind::Normal, vec![baseline], "left.txt", 0x23)?;
    publish_ref(&layout, "heads/left", left)?;

    let report = prepare_merge_evidence(
        &layout,
        baseline,
        MergeEvidenceTarget::Ref("heads/left".to_string()),
        MergeEvidenceTarget::Block(baseline),
    )?;

    assert_eq!(report.left_selector.selector, "ref heads/left");
    assert_eq!(report.left_selector.target_block_id, left);
    assert_eq!(report.right_operation_count, 0);
    assert!(matches!(
        report.outcome,
        "Confluent" | "Unsupported" | "InvalidCandidate"
    ));
    let _ = std::fs::remove_dir_all(root);
    Ok(())
}

#[test]
fn empty_sequences_are_report_level_success() -> Result<()> {
    let root = unique_temp_dir("merge-evidence-empty-sequences");
    let layout = RepositoryLayout::init(root.clone())?;
    let baseline = write_block(&layout, BlockKind::Root, Vec::new(), Vec::new())?;

    let report = prepare_merge_evidence(
        &layout,
        baseline,
        MergeEvidenceTarget::Block(baseline),
        MergeEvidenceTarget::Block(baseline),
    )?;

    assert_eq!(report.outcome, "Confluent");
    assert_eq!(report.reason, Some("proven_confluent"));
    assert_eq!(report.left_operation_count, 0);
    assert_eq!(report.right_operation_count, 0);
    let _ = std::fs::remove_dir_all(root);
    Ok(())
}

#[test]
fn cross_display_preserves_distinct_left_and_right_operations() -> Result<()> {
    let root = unique_temp_dir("merge-evidence-cross-display-sides");
    let layout = RepositoryLayout::init(root.clone())?;
    let node_id = NodeId::from_bytes([0x41; 32]);
    let blob_id = write_blob(&layout, b"tracked.txt\n")?;
    let baseline = write_operation_block(
        &layout,
        BlockKind::Root,
        Vec::new(),
        OperationKind::CreateFile(CreateFile {
            path: "tracked.txt".to_string(),
            node_id,
            blob_id,
            mode: 0o100_644,
        }),
    )?;
    let left = write_operation_block(
        &layout,
        BlockKind::Normal,
        vec![baseline],
        OperationKind::ChangePerm(ChangePerm {
            node_id,
            old_mode: 0o100_644,
            new_mode: 0o100_755,
        }),
    )?;
    let right = write_operation_block(
        &layout,
        BlockKind::Normal,
        vec![baseline],
        OperationKind::DeleteNode(DeleteNode {
            path: "tracked.txt".to_string(),
            node_id,
            old_node_kind: NodeKind::TextFile,
            preimage: DeleteNodePreimage::File {
                old_blob_id: blob_id,
                old_mode: 0o100_644,
            },
        }),
    )?;

    let report = prepare_merge_evidence(
        &layout,
        baseline,
        MergeEvidenceTarget::Block(left),
        MergeEvidenceTarget::Block(right),
    )?;

    assert_eq!(report.outcome, "Conflict");
    assert_eq!(report.reason, Some("pair_conflict"));
    let Some(cross_item) = report.items.iter().find(|item| item.side == "cross") else {
        panic!("missing cross display item");
    };
    let Some(left_operation) = cross_item.operation.as_ref() else {
        panic!("missing left operation summary");
    };
    let Some(right_operation) = cross_item.peer_operation.as_ref() else {
        panic!("missing right operation summary");
    };
    assert_eq!(left_operation.index, 0);
    assert_eq!(left_operation.op_seq, Some(1));
    assert_eq!(left_operation.kind, Some("ChangePerm"));
    assert_eq!(left_operation.path, None);
    assert_eq!(right_operation.index, 0);
    assert_eq!(right_operation.op_seq, Some(1));
    assert_eq!(right_operation.kind, Some("DeleteNode"));
    assert_eq!(right_operation.path.as_deref(), Some("tracked.txt"));
    let _ = std::fs::remove_dir_all(root);
    Ok(())
}

#[test]
fn missing_ancestry_fails_before_report() -> Result<()> {
    let root = unique_temp_dir("merge-evidence-missing-ancestry");
    let layout = RepositoryLayout::init(root.clone())?;
    let baseline = write_block(&layout, BlockKind::Root, Vec::new(), Vec::new())?;
    let other_root = write_create_block(&layout, BlockKind::Root, Vec::new(), "other.txt", 0x24)?;

    let err = match prepare_merge_evidence(
        &layout,
        baseline,
        MergeEvidenceTarget::Block(other_root),
        MergeEvidenceTarget::Block(baseline),
    ) {
        Ok(_) => panic!("missing ancestry unexpectedly succeeded"),
        Err(err) => err,
    };

    assert!(err.to_string().contains("is not an ancestor"));
    let _ = std::fs::remove_dir_all(root);
    Ok(())
}

#[test]
fn multi_parent_candidate_fails_before_report() -> Result<()> {
    let root = unique_temp_dir("merge-evidence-multi-parent-candidate");
    let layout = RepositoryLayout::init(root.clone())?;
    let baseline = write_block(&layout, BlockKind::Root, Vec::new(), Vec::new())?;
    let other_parent = write_create_block(
        &layout,
        BlockKind::Root,
        Vec::new(),
        "other-parent.txt",
        0x25,
    )?;
    let mut parents = vec![baseline, other_parent];
    parents.sort();
    let target = write_block(&layout, BlockKind::Normal, parents, Vec::new())?;

    let err = match prepare_merge_evidence(
        &layout,
        baseline,
        MergeEvidenceTarget::Block(target),
        MergeEvidenceTarget::Block(baseline),
    ) {
        Ok(_) => panic!("multi-parent candidate unexpectedly succeeded"),
        Err(err) => err,
    };

    assert!(err.to_string().contains("single-parent candidate chains"));
    let _ = std::fs::remove_dir_all(root);
    Ok(())
}

#[test]
fn missing_or_wrong_type_patch_fails_before_report() -> Result<()> {
    let root = unique_temp_dir("merge-evidence-missing-patch");
    let layout = RepositoryLayout::init(root.clone())?;
    let baseline = write_block(&layout, BlockKind::Root, Vec::new(), Vec::new())?;
    let wrong_type_id = write_blob(&layout, b"not a patch")?;
    let target = write_block(
        &layout,
        BlockKind::Normal,
        vec![baseline],
        vec![wrong_type_id],
    )?;

    let err = match prepare_merge_evidence(
        &layout,
        baseline,
        MergeEvidenceTarget::Block(target),
        MergeEvidenceTarget::Block(baseline),
    ) {
        Ok(_) => panic!("wrong-type patch unexpectedly succeeded"),
        Err(err) => err,
    };

    let error = err.to_string();
    assert!(
        error.contains("Patch") || error.contains("object type mismatch"),
        "unexpected error: {error}"
    );
    let _ = std::fs::remove_dir_all(root);
    Ok(())
}

pub(super) fn write_create_block(
    layout: &RepositoryLayout,
    kind: BlockKind,
    parents: Vec<ObjectId>,
    path: &str,
    node_byte: u8,
) -> Result<ObjectId> {
    let mut store = FileObjectStore::new(layout.clone());
    let blob = BlobPayload::new(BlobKind::Text, format!("{path}\n").into_bytes());
    let blob_bytes = blob.to_canonical_bytes()?;
    let blob_id = ObjectId::from_canonical_payload(ObjectType::Blob, 1, &blob_bytes);
    let mut blob_env = ObjectEnvelope::unsigned(ObjectType::Blob, 1, blob_bytes);
    blob_env.add_signature(maintainer_signature())?;
    store.write_object(&blob_env)?;

    let patch = PatchPayload {
        operations: vec![Operation {
            op_seq: 1,
            op_id: None,
            preconditions: Vec::new(),
            kind: OperationKind::CreateFile(CreateFile {
                path: path.to_string(),
                node_id: NodeId::from_bytes([node_byte; 32]),
                blob_id,
                mode: 0o100_644,
            }),
        }],
        parent_patch_ids: Vec::new(),
        intent: None,
        preconditions: Vec::new(),
        purpose: PatchPurpose::Normal,
    };
    write_patch_block(layout, kind, parents, patch)
}

pub(super) fn write_operation_block(
    layout: &RepositoryLayout,
    kind: BlockKind,
    parents: Vec<ObjectId>,
    operation: OperationKind,
) -> Result<ObjectId> {
    let patch = PatchPayload {
        operations: vec![Operation {
            op_seq: 1,
            op_id: None,
            preconditions: Vec::new(),
            kind: operation,
        }],
        parent_patch_ids: Vec::new(),
        intent: None,
        preconditions: Vec::new(),
        purpose: PatchPurpose::Normal,
    };
    write_patch_block(layout, kind, parents, patch)
}

fn write_patch_block(
    layout: &RepositoryLayout,
    kind: BlockKind,
    parents: Vec<ObjectId>,
    patch: PatchPayload,
) -> Result<ObjectId> {
    let mut store = FileObjectStore::new(layout.clone());
    let mut patch_env = ObjectEnvelope::unsigned(ObjectType::Patch, 1, patch.to_canonical_bytes()?);
    patch_env.add_signature(dummy_signature())?;
    let patch_id = store.write_object(&patch_env)?;
    write_block(layout, kind, parents, vec![patch_id])
}

pub(super) fn write_blob(layout: &RepositoryLayout, content: &[u8]) -> Result<ObjectId> {
    let mut store = FileObjectStore::new(layout.clone());
    let blob = BlobPayload::new(BlobKind::Text, content.to_vec());
    let mut envelope = ObjectEnvelope::unsigned(ObjectType::Blob, 1, blob.to_canonical_bytes()?);
    envelope.add_signature(maintainer_signature())?;
    store.write_object(&envelope)
}

pub(super) fn write_block(
    layout: &RepositoryLayout,
    kind: BlockKind,
    parents: Vec<ObjectId>,
    patches: Vec<ObjectId>,
) -> Result<ObjectId> {
    let mut store = FileObjectStore::new(layout.clone());
    let block = signed_block(kind, parents, patches, None);
    store.write_object(&block)
}

fn publish_ref(layout: &RepositoryLayout, ref_name: &str, block_id: ObjectId) -> Result<()> {
    let ref_store = RefStore::new(layout.clone());
    let ref_state = signed_ref_state_envelope(ref_name, None, block_id, 1);
    let ref_state_id = ref_state.object_id();
    let ref_update = signed_ref_update_envelope(ref_name, None, ref_state_id, block_id, 1);
    ref_store
        .publish(&RefPublication {
            ref_name: ref_name.to_string(),
            expected_previous_ref_state_id: None,
            ref_state,
            ref_update,
        })
        .map(|_| ())
}