haematite 0.6.1

Content-addressed, branchable, actor-native storage engine
Documentation
//! A5 on-disk format version stamp tests.
//!
//! Pins the compat discipline end to end: create stamps the current version
//! and reopens; a LEGACY config (no stamp) opens with version-1 semantics and
//! is NOT silently rewritten; a future-version stamp refuses with the typed
//! [`DatabaseError::FormatVersionTooNew`] whose message names both versions;
//! a never-issued stamp (`0`) is rejected as a parse failure.

use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::Value;

use super::{Database, DatabaseConfig, DatabaseError, ON_DISK_FORMAT_VERSION};

fn config_for(path: &Path, shard_count: usize) -> DatabaseConfig {
    DatabaseConfig {
        data_dir: path.to_path_buf(),
        shard_count,
        distributed: None,
    }
}

/// Create (and immediately close) a database so the directory holds a freshly
/// stamped `config.json`, returning the data-dir path inside `dir`.
fn created_data_dir(dir: &tempfile::TempDir) -> Result<PathBuf, Box<dyn Error>> {
    let data_dir = dir.path().join("db");
    let db = Database::create(config_for(&data_dir, 2))?;
    drop(db);
    Ok(data_dir)
}

fn read_config_value(data_dir: &Path) -> Result<Value, Box<dyn Error>> {
    let bytes = fs::read(data_dir.join("config.json"))?;
    Ok(serde_json::from_slice(&bytes)?)
}

/// Rewrite `config.json` with `mutate` applied to its JSON object. Direct
/// `fs::write` is fine HERE: the test is forging what an on-disk file from a
/// different binary would contain, not exercising the engine's write path.
fn rewrite_config(
    data_dir: &Path,
    mutate: impl FnOnce(&mut serde_json::Map<String, Value>) -> Result<(), Box<dyn Error>>,
) -> Result<(), Box<dyn Error>> {
    let mut value = read_config_value(data_dir)?;
    let object = value
        .as_object_mut()
        .ok_or("config.json must be a JSON object")?;
    mutate(object)?;
    fs::write(
        data_dir.join("config.json"),
        serde_json::to_vec_pretty(&value)?,
    )?;
    Ok(())
}

/// Restamp the directory's config with an arbitrary `format_version`, forging
/// a directory written by a different (e.g. newer) haematite binary.
fn install_format_version(data_dir: &Path, version: u32) -> Result<(), Box<dyn Error>> {
    rewrite_config(data_dir, |object| {
        object.insert("format_version".to_owned(), Value::from(version));
        Ok(())
    })
}

#[test]
fn create_stamps_current_format_version_and_reopens() -> Result<(), Box<dyn Error>> {
    let dir = tempfile::tempdir()?;
    let data_dir = created_data_dir(&dir)?;

    // Create stamped the CURRENT format version into config.json.
    let value = read_config_value(&data_dir)?;
    assert_eq!(
        value.get("format_version"),
        Some(&Value::from(ON_DISK_FORMAT_VERSION)),
        "create must stamp the current on-disk format version"
    );

    // Round-trip: the stamped directory opens, and the stamp survives the
    // reopen unchanged (open never rewrites config.json).
    let reopened = Database::open(&data_dir)?;
    drop(reopened);
    let value = read_config_value(&data_dir)?;
    assert_eq!(
        value.get("format_version"),
        Some(&Value::from(ON_DISK_FORMAT_VERSION)),
        "the stamp must survive a reopen unchanged"
    );
    Ok(())
}

#[test]
fn legacy_config_without_stamp_opens_and_is_not_rewritten() -> Result<(), Box<dyn Error>> {
    let dir = tempfile::tempdir()?;
    let data_dir = created_data_dir(&dir)?;

    // Forge a LEGACY (pre-A5) directory: strip the stamp entirely.
    rewrite_config(&data_dir, |object| {
        object
            .remove("format_version")
            .ok_or("freshly created config must carry a format_version to remove")?;
        Ok(())
    })?;
    let legacy_bytes = fs::read(data_dir.join("config.json"))?;

    // Absent stamp = version-1 semantics: the legacy directory opens...
    let db = Database::open(&data_dir)?;
    drop(db);

    // ...and open did NOT silently rewrite the file — it stays byte-identical,
    // so the old binary that wrote it can still open it too.
    let after_open = fs::read(data_dir.join("config.json"))?;
    assert_eq!(
        legacy_bytes, after_open,
        "opening a legacy config must not rewrite config.json"
    );
    Ok(())
}

#[test]
fn future_format_version_refuses_with_typed_error() -> Result<(), Box<dyn Error>> {
    let dir = tempfile::tempdir()?;
    let data_dir = created_data_dir(&dir)?;
    let future = ON_DISK_FORMAT_VERSION + 1;
    install_format_version(&data_dir, future)?;

    // Downgrade refusal: this (now too-old) binary must fail with the exact
    // typed variant carrying BOTH versions, never open or misread the dir.
    let result = Database::open(&data_dir);
    assert!(
        matches!(
            result,
            Err(DatabaseError::FormatVersionTooNew { found, supported })
                if found == future && supported == ON_DISK_FORMAT_VERSION
        ),
        "a future-version stamp must refuse with FormatVersionTooNew naming both versions"
    );
    Ok(())
}

#[test]
fn future_format_refusal_message_names_both_versions() -> Result<(), Box<dyn Error>> {
    let dir = tempfile::tempdir()?;
    let data_dir = created_data_dir(&dir)?;
    let future = ON_DISK_FORMAT_VERSION + 1;
    install_format_version(&data_dir, future)?;

    let Err(error) = Database::open(&data_dir) else {
        return Err("opening a future-format directory must fail".into());
    };
    let message = error.to_string();
    assert!(
        message.contains(&format!("format version {future}")),
        "refusal must name the on-disk version: {message}"
    );
    assert!(
        message.contains(&format!("({ON_DISK_FORMAT_VERSION})")),
        "refusal must name the supported version: {message}"
    );
    Ok(())
}

#[test]
fn create_refuses_already_initialised_dir() -> Result<(), Box<dyn Error>> {
    let dir = tempfile::tempdir()?;
    let data_dir = created_data_dir(&dir)?;
    let before = fs::read(data_dir.join("config.json"))?;

    // A5: create over an existing database (no live writer — the creator was
    // dropped) must refuse with the typed variant, not clobber the config.
    let result = Database::create(config_for(&data_dir, 8));
    assert!(
        matches!(
            result,
            Err(DatabaseError::DataDirAlreadyInitialised { ref config_path })
                if *config_path == data_dir.join("config.json")
        ),
        "create over an initialised dir must refuse with DataDirAlreadyInitialised"
    );
    let after = fs::read(data_dir.join("config.json"))?;
    assert_eq!(
        before, after,
        "the refusal must leave the existing config byte-identical"
    );

    // The refused create must not have deleted the pre-existing database
    // either: it still opens.
    let reopened = Database::open(&data_dir)?;
    drop(reopened);
    Ok(())
}

#[test]
fn create_refuses_newer_format_dir_without_clobbering() -> Result<(), Box<dyn Error>> {
    let dir = tempfile::tempdir()?;
    let data_dir = created_data_dir(&dir)?;
    let future = ON_DISK_FORMAT_VERSION + 1;
    install_format_version(&data_dir, future)?;
    let before = fs::read(data_dir.join("config.json"))?;

    // A5: create pointed at a NEWER binary's directory surfaces the sharper
    // downgrade refusal — and must not restamp the newer config with this
    // binary's version.
    let result = Database::create(config_for(&data_dir, 2));
    assert!(
        matches!(
            result,
            Err(DatabaseError::FormatVersionTooNew { found, supported })
                if found == future && supported == ON_DISK_FORMAT_VERSION
        ),
        "create over a newer-format dir must refuse with FormatVersionTooNew"
    );
    let after = fs::read(data_dir.join("config.json"))?;
    assert_eq!(
        before, after,
        "the refusal must leave the newer config byte-identical"
    );
    Ok(())
}

#[test]
fn never_issued_format_version_zero_is_rejected() -> Result<(), Box<dyn Error>> {
    let dir = tempfile::tempdir()?;
    let data_dir = created_data_dir(&dir)?;
    install_format_version(&data_dir, 0)?;

    // Version 0 was never issued (stamping began at 1): it is a malformed
    // file, not a "newer binary" — so it parses-fails rather than reading as
    // any real version or as the FormatVersionTooNew downgrade refusal.
    let result = Database::open(&data_dir);
    assert!(
        matches!(result, Err(DatabaseError::ConfigParse(ref message)) if message.contains("never issued")),
        "format_version 0 must be rejected as a parse failure"
    );
    Ok(())
}