shipper-core 0.4.0

Core library behind the `shipper` CLI: engine, planning, state, registry, and remediation primitives for `cargo publish` workspaces.
Documentation
use std::path::{Path, PathBuf};

use anyhow::Result;
use chrono::{DateTime, Utc};

use crate::engine::Reporter;
use crate::plan::PlannedWorkspace;
use crate::state::events;
use crate::state::execution_state as state;
use crate::types::{
    AuthEvidence, EnvironmentFingerprint, EventType, ExecutionResult, ExecutionState, GitContext,
    PackageReceipt, PackageState, PublishEvent, Receipt, RuntimeOptions,
};
use crate::webhook::{self, WebhookEvent};

pub(in crate::engine) fn record_consistency_drift(
    events_path: &Path,
    state: &ExecutionState,
    event_log: &mut events::EventLog,
    reporter: &mut dyn Reporter,
) {
    match crate::state::consistency::verify_events_state_consistency(events_path, state) {
        Ok(drift) if !drift.is_consistent() => {
            reporter.warn(&crate::state::consistency::format_drift_summary(&drift));
            event_log.record(PublishEvent {
                timestamp: Utc::now(),
                event_type: EventType::StateEventDriftDetected { drift },
                package: "all".to_string(),
            });
        }
        Ok(_) => {}
        Err(e) => reporter.warn(&format!("end-of-run consistency check failed: {e}")),
    }
}

#[allow(clippy::too_many_arguments)]
pub(in crate::engine) fn finish_sequential_run(
    ws: &PlannedWorkspace,
    opts: &RuntimeOptions,
    state_dir: &Path,
    events_path: &Path,
    event_log: &mut events::EventLog,
    state: &ExecutionState,
    receipts: Vec<PackageReceipt>,
    run_started: DateTime<Utc>,
    git_context: Option<GitContext>,
    environment: EnvironmentFingerprint,
    auth_evidence: AuthEvidence,
) -> Result<Receipt> {
    let exec_result = sequential_execution_result(&receipts);
    event_log.record(PublishEvent {
        timestamp: Utc::now(),
        event_type: EventType::ExecutionFinished {
            result: exec_result.clone(),
        },
        package: "all".to_string(),
    });
    event_log.write_to_file(events_path)?;

    send_completion_webhook(ws, opts, &receipts, &exec_result);

    write_receipt(
        ws,
        state_dir,
        state,
        receipts,
        run_started,
        git_context,
        environment,
        auth_evidence,
        events_path,
    )
}

#[allow(clippy::too_many_arguments)]
pub(in crate::engine) fn finish_parallel_run(
    ws: &PlannedWorkspace,
    opts: &RuntimeOptions,
    state_dir: &Path,
    events_path: &Path,
    event_log: &mut events::EventLog,
    state: &ExecutionState,
    receipts: Vec<PackageReceipt>,
    run_started: DateTime<Utc>,
    git_context: Option<GitContext>,
    environment: EnvironmentFingerprint,
    auth_evidence: AuthEvidence,
) -> Result<Receipt> {
    let exec_result = parallel_execution_result(&receipts);
    event_log.record(PublishEvent {
        timestamp: Utc::now(),
        event_type: EventType::ExecutionFinished {
            result: exec_result.clone(),
        },
        package: "all".to_string(),
    });
    event_log.write_to_file(events_path)?;

    send_completion_webhook(ws, opts, &receipts, &exec_result);

    write_receipt(
        ws,
        state_dir,
        state,
        receipts,
        run_started,
        git_context,
        environment,
        auth_evidence,
        events_path,
    )
}

#[allow(clippy::too_many_arguments)]
fn write_receipt(
    ws: &PlannedWorkspace,
    state_dir: &Path,
    state: &ExecutionState,
    receipts: Vec<PackageReceipt>,
    run_started: DateTime<Utc>,
    git_context: Option<GitContext>,
    environment: EnvironmentFingerprint,
    auth_evidence: AuthEvidence,
    events_path: &Path,
) -> Result<Receipt> {
    let receipt = Receipt {
        receipt_version: "shipper.receipt.v2".to_string(),
        plan_id: ws.plan.plan_id.clone(),
        registry: ws.plan.registry.clone(),
        started_at: run_started,
        finished_at: Utc::now(),
        packages: receipts,
        event_log_path: PathBuf::from(state_dir).join("events.jsonl"),
        git_context,
        environment,
        auth_evidence: Some(auth_evidence),
    };

    let reconciliation_report = crate::state::reconciliation::write_report_from_events(
        state_dir,
        &ws.plan.plan_id,
        &ws.plan.registry,
        events_path,
    )?;
    crate::state::consistency::verify_finalization_consistency(
        events_path,
        state,
        &receipt,
        reconciliation_report.as_ref(),
    )?;
    state::write_receipt(state_dir, &receipt)?;
    Ok(receipt)
}

fn sequential_execution_result(receipts: &[PackageReceipt]) -> ExecutionResult {
    if receipts
        .iter()
        .all(|r| is_successful_terminal_state(&r.state))
    {
        ExecutionResult::Success
    } else {
        ExecutionResult::PartialFailure
    }
}

fn parallel_execution_result(receipts: &[PackageReceipt]) -> ExecutionResult {
    if receipts
        .iter()
        .all(|r| is_successful_terminal_state(&r.state))
    {
        ExecutionResult::Success
    } else {
        ExecutionResult::PartialFailure
    }
}

fn is_successful_terminal_state(state: &PackageState) -> bool {
    matches!(
        state,
        PackageState::Published | PackageState::Skipped { .. }
    )
}

fn send_completion_webhook(
    ws: &PlannedWorkspace,
    opts: &RuntimeOptions,
    receipts: &[PackageReceipt],
    exec_result: &ExecutionResult,
) {
    let total_packages = receipts.len();
    let success_count = receipts
        .iter()
        .filter(|r| matches!(r.state, PackageState::Published))
        .count();
    let failure_count = receipts
        .iter()
        .filter(|r| !is_successful_terminal_state(&r.state))
        .count();
    let skipped_count = receipts
        .iter()
        .filter(|r| matches!(r.state, PackageState::Skipped { .. }))
        .count();

    webhook::maybe_send_event(
        &opts.webhook,
        WebhookEvent::PublishCompleted {
            plan_id: ws.plan.plan_id.clone(),
            total_packages,
            success_count,
            failure_count,
            skipped_count,
            result: match exec_result {
                ExecutionResult::Success => "success".to_string(),
                ExecutionResult::PartialFailure => "partial_failure".to_string(),
                ExecutionResult::CompleteFailure => "complete_failure".to_string(),
            },
        },
    );
}

#[cfg(test)]
mod tests {
    use chrono::Utc;

    use super::{
        is_successful_terminal_state, parallel_execution_result, sequential_execution_result,
    };
    use crate::types::{ExecutionResult, PackageEvidence, PackageReceipt, PackageState};

    fn receipt(state: PackageState) -> PackageReceipt {
        let now = Utc::now();
        PackageReceipt {
            name: "demo".to_string(),
            version: "0.1.0".to_string(),
            attempts: 1,
            state,
            started_at: now,
            finished_at: now,
            duration_ms: 0,
            evidence: PackageEvidence {
                attempts: vec![],
                readiness_checks: vec![],
            },
            compromised_at: None,
            compromised_by: None,
            superseded_by: None,
        }
    }

    #[test]
    fn sequential_uploaded_receipt_is_not_terminal_success() {
        let receipts = [receipt(PackageState::Uploaded)];

        assert_eq!(
            sequential_execution_result(&receipts),
            ExecutionResult::PartialFailure
        );
    }

    #[test]
    fn parallel_uploaded_receipt_is_not_terminal_success() {
        let receipts = [receipt(PackageState::Uploaded)];

        assert_eq!(
            parallel_execution_result(&receipts),
            ExecutionResult::PartialFailure
        );
    }

    #[test]
    fn completion_metrics_count_uploaded_as_failure() {
        let receipts = [receipt(PackageState::Uploaded)];
        let failure_count = receipts
            .iter()
            .filter(|receipt| !is_successful_terminal_state(&receipt.state))
            .count();

        assert_eq!(failure_count, 1);
    }
}