powercut 0.1.1

Prove an acknowledged write survives a power cut or process crash, in cargo test. A database-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.
///
/// Every fallible entry point returns this, so a harness of your own can
/// propagate with `?` and keep one error type.
///
/// ```
/// use powercut::{CrashState, DurabilityWitness, ScratchDir, SyncJournal, Tier};
///
/// /// Capture a store and report how many files survived a power cut.
/// fn surviving_files(store: &std::path::Path, w: &SyncJournal) -> powercut::Result<usize> {
///     let crash = CrashState::capture(store, Tier::PowerCut, w)?;   // `?` on powercut::Error
///     Ok(crash.manifest().len())
/// }
///
/// let store = ScratchDir::new("doc-result").unwrap();
/// std::fs::write(store.path().join("app.db"), b"committed").unwrap();
/// let journal = SyncJournal::complete();
/// journal.sync_path(store.path().join("app.db")).unwrap();
///
/// assert_eq!(surviving_files(store.path(), &journal).unwrap(), 1);
///
/// // The error case carries the same type, so callers need only one match arm.
/// let missing = surviving_files(std::path::Path::new("/no/such/store"), &journal);
/// assert!(matches!(missing, Err(powercut::Error::StoreMissing(_))));
/// ```
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"));
///
/// // The lock case says what to do about it rather than handing you `os error 33`.
/// let locked = Error::Locked {
///     path: "C:\\store\\data.redb".into(),
///     source: std::io::Error::from_raw_os_error(33),
/// };
/// assert!(locked.to_string().contains("exclusive"));
/// assert!(locked.to_string().contains("macOS or Linux"));
/// ```
///
/// The enum is `#[non_exhaustive]`, so match on it with a `_` arm — new
/// variants arrive in patch releases.
#[non_exhaustive]
#[derive(Debug)]
pub enum Error {
    /// The store directory does not exist or is not a directory.
    StoreMissing(PathBuf),
    /// A store file could not be read for the capture because another process
    /// holds an exclusive lock over it — in practice, always Windows.
    ///
    /// `powercut` copies the store's bytes while the engine is still open, and
    /// a byte range under an exclusive Windows lock is unreadable through any
    /// other handle. redb takes such a lock for as long as the database is
    /// open; SQLite does not, because its locks sit beyond EOF.
    ///
    /// The unreadable file is **not** skipped: a store file missing from a
    /// capture would manufacture a false
    /// [`Lost`](crate::Verdict::Lost). See *Capturing a store on Windows* in
    /// the crate documentation.
    Locked {
        /// The file that could not be read.
        path: PathBuf,
        /// The underlying operating-system error.
        source: std::io::Error,
    },
    /// 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 }
    }

    /// Classify a failure to read a store file during a capture.
    ///
    /// Windows reports an exclusive byte-range lock as `ERROR_LOCK_VIOLATION`
    /// and an incompatible share mode as `ERROR_SHARING_VIOLATION`, both of
    /// which surface as an opaque `os error 3x`. That message tells nobody
    /// that the answer is "measure this engine on macOS or Linux", so the
    /// capture turns it into [`Error::Locked`] instead.
    ///
    /// The check is `cfg`-gated: these are Windows error *numbers*, and 32/33
    /// mean unrelated things in a Unix `errno`.
    pub(crate) fn from_capture_read(path: &std::path::Path, source: std::io::Error) -> Self {
        #[cfg(windows)]
        {
            /// `ERROR_SHARING_VIOLATION` (32) and `ERROR_LOCK_VIOLATION` (33).
            const WINDOWS_LOCK_CODES: [i32; 2] = [32, 33];
            if source
                .raw_os_error()
                .is_some_and(|code| WINDOWS_LOCK_CODES.contains(&code))
            {
                return Error::Locked {
                    path: path.to_path_buf(),
                    source,
                };
            }
        }
        Error::io(Some(path.to_path_buf()), 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::Locked { path, source } => write!(
                f,
                "cannot read `{}` for the capture: another process holds an exclusive lock over \
                 it ({source}). powercut copies a store's bytes while the engine is still open, \
                 and a byte range under an exclusive Windows lock is unreadable through any \
                 other handle — redb takes one for as long as the database is open; SQLite does \
                 not, because its locks sit beyond EOF. The file is NOT skipped, because a store \
                 file missing from a capture would manufacture a false `Lost`. Measure this \
                 engine on macOS or Linux, or drive it so that no handle is open at the ack.",
                path.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, .. } | Error::Locked { 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")
    );
}