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,
}
}
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)?)
}
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(())
}
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)?;
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"
);
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)?;
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"))?;
let db = Database::open(&data_dir)?;
drop(db);
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)?;
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"))?;
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"
);
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"))?;
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)?;
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(())
}