powercut 0.1.0

Prove your acknowledged write survives the power cut, in cargo test. A DB-agnostic crash-injection harness that derives the durable set from the fsyncs that actually happened.
Documentation
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![doc = include_str!("../README.md")]

mod baseline;
mod capture;
mod report;
mod scenario;
mod scratch;
mod witness;

pub use baseline::{
    assert_no_worse_than, compare_to_baseline, Baseline, BaselineEntry, UPDATE_ENV,
};
pub use capture::{CapturedFile, CrashState, Tier, Warrant};
pub use report::{Checker, Finding, IsAbsent, Report, Verdict, Violation, ViolationKind};
pub use scenario::{Acked, Scenario};
pub use scratch::ScratchDir;
pub use witness::{
    AssumedDurable, Barrier, Coverage, DurabilityWitness, SyncJournal, SyncKind, SyncRecord,
};

use std::path::PathBuf;

/// The crate's result type.
pub type Result<T> = std::result::Result<T, Error>;

/// Everything that can go wrong inside the harness itself.
///
/// Note what is *not* here: a durability failure. Data loss is a
/// [`Finding`], not an error — the harness succeeded at its job when it finds
/// some.
///
/// ```
/// # use powercut::Error;
/// let e = Error::StoreMissing("/no/such/store".into());
/// assert!(e.to_string().contains("/no/such/store"));
/// ```
#[non_exhaustive]
#[derive(Debug)]
pub enum Error {
    /// The store directory does not exist or is not a directory.
    StoreMissing(PathBuf),
    /// An I/O failure, with the path it happened to when one is known.
    Io {
        /// The path involved, if any.
        path: Option<PathBuf>,
        /// The underlying error.
        source: std::io::Error,
    },
}

impl Error {
    pub(crate) fn io(path: Option<PathBuf>, source: std::io::Error) -> Self {
        Error::Io { path, source }
    }
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Error::StoreMissing(p) => write!(
                f,
                "the store directory `{}` does not exist. `Scenario::new` wants the directory \
                 your engine keeps its files in, and it must exist before the workload runs.",
                p.display()
            ),
            Error::Io {
                path: Some(p),
                source,
            } => write!(f, "i/o error at `{}`: {source}", p.display()),
            Error::Io { path: None, source } => write!(f, "i/o error: {source}"),
        }
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Error::Io { source, .. } => Some(source),
            Error::StoreMissing(_) => None,
        }
    }
}

impl From<std::io::Error> for Error {
    fn from(source: std::io::Error) -> Self {
        Error::Io { path: None, source }
    }
}

/// Render a set of reports as a markdown table — the matrix you paste into a
/// README or a pull request.
///
/// ```
/// # use powercut::to_markdown;
/// let table = to_markdown(&[]);
/// assert!(table.contains("| case | tier | verdict | detail |"));
/// ```
#[must_use]
pub fn to_markdown(reports: &[Report]) -> String {
    let mut out = String::from("| case | tier | verdict | detail |\n|---|---|---|---|\n");
    for report in reports {
        out.push_str(&report.to_markdown_row());
        out.push('\n');
    }
    out
}

/// Print every report to stdout, then panic if any of them is not
/// [`Verdict::Survived`].
///
/// The printing is unconditional on purpose: a green run that shows its
/// manifest is how somebody notices that the durable set has quietly become
/// empty.
///
/// # Panics
///
/// If any report's verdict is not `Survived`.
///
/// ```
/// # use powercut::{assert_all_survived, Report};
/// let reports: Vec<Report> = Vec::new();
/// assert_all_survived(&reports);
/// ```
pub fn assert_all_survived(reports: &[Report]) {
    for report in reports {
        println!("{}", report.to_text());
    }
    let failed: Vec<&Report> = reports.iter().filter(|r| !r.verdict().is_pass()).collect();
    assert!(
        failed.is_empty(),
        "\npowercut: {} of {} case(s) did not survive:\n\n{}\n",
        failed.len(),
        reports.len(),
        failed
            .iter()
            .map(|r| r.to_text())
            .collect::<Vec<_>>()
            .join("\n")
    );
}