use std::num::NonZeroU64;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::io::atomically_write_file_bytes;
pub const CURRENT_DB_VERSION: u32 = 4;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub(crate) struct DbSettings {
pub version: u32,
pub dir_tree_is_pre_created: bool,
pub num_ops_per_wal: NonZeroU64,
}
#[derive(Error, Debug)]
pub enum SettingsError {
#[error("Failed to read settings file: {0}")]
ReadFile(#[source] std::io::Error),
#[error("Failed to parse settings: {0}")]
ParseFailed(#[from] serde_json::Error),
#[error("Settings validation failed: {0}")]
ValidationFailed(String),
#[error("Database paths must reside on the same filesystem")]
InvalidPaths,
#[error(transparent)]
AtomicWrite(#[from] crate::io::IoError),
#[error("Unsupported database version: {found}, expected: {expected}")]
UnsupportedVersion { found: u32, expected: u32 },
}
pub(crate) struct SettingsPersister {
settings_path: PathBuf,
}
impl SettingsPersister {
pub fn new(settings_path: PathBuf) -> Self {
Self { settings_path }
}
pub fn load(&self) -> Result<Option<DbSettings>, SettingsError> {
match std::fs::read_to_string(&self.settings_path) {
Ok(content) => {
let settings: DbSettings =
serde_json::from_str(&content).map_err(SettingsError::ParseFailed)?;
if settings.version != CURRENT_DB_VERSION {
return Err(SettingsError::UnsupportedVersion {
found: settings.version,
expected: CURRENT_DB_VERSION,
});
}
Ok(Some(settings))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(SettingsError::ReadFile(e)),
}
}
pub fn save(&self, settings: &DbSettings) -> Result<(), SettingsError> {
let serialized = serde_json::to_string(settings).unwrap();
let temp_path = self.settings_path.with_extension("json.tmp");
atomically_write_file_bytes(&self.settings_path, &temp_path, serialized)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use tempfile::tempdir;
use super::*;
use crate::tests::utils::directory_helpers::{
analyze_directory_structure, count_directories_recursive, count_leaf_directories,
};
use crate::{Cas, Config, LibError, SyncMode};
#[test]
fn test_pre_create_cas_dirs() -> Result<()> {
let dir = tempdir()?;
let db_path = dir.path();
let config = Config {
sync_mode: SyncMode::Sync,
num_ops_per_wal: NonZeroU64::new(100).unwrap(),
pre_create_cas_dirs: true,
..Default::default()
};
{
let cas = Cas::open(db_path, config.clone())?;
let cas_root = db_path.join("cas");
let total_dir_count = count_directories_recursive(&cas_root)?;
assert_eq!(total_dir_count, 65792, "want 65,792 total dirs");
let leaf_dir_count = count_leaf_directories(&cas_root)?;
assert_eq!(leaf_dir_count, 65536, "want 65,536 leaf dirs");
let structure = analyze_directory_structure(&cas_root)?;
assert_eq!(structure.depth, 2, "depth != 2");
let [top_level, second_level, ..] = structure.dirs_per_level.as_slice() else {
panic!("Expected at least two directory levels");
};
assert_eq!(*top_level, 256, "need 256 top-level");
assert_eq!(*second_level, 65536, "need 65,536 second-level");
let mut tx = cas.put("test_key".to_string())?;
tx.write(b"test data")?;
tx.finish()?;
let data = cas.get(&"test_key".to_string())?;
assert_eq!(data, Some(bytes::Bytes::from("test data")));
}
{
let cas = Cas::open(db_path, config)?;
let mut tx = cas.put("test_key2".to_string())?;
tx.write(b"test data 2")?;
tx.finish()?;
let data = cas.get(&"test_key2".to_string())?;
assert_eq!(data, Some(bytes::Bytes::from("test data 2")));
}
Ok(())
}
#[test]
fn test_immutable_settings_validation() -> Result<()> {
let dir = tempdir()?;
let db_path = dir.path();
let config = Config {
sync_mode: SyncMode::Sync,
num_ops_per_wal: NonZeroU64::new(1000).unwrap(),
pre_create_cas_dirs: false,
..Default::default()
};
{
let _cas = Cas::<String>::open(db_path, config)?;
}
let config2 = Config {
sync_mode: SyncMode::Sync,
num_ops_per_wal: NonZeroU64::new(2000).unwrap(), pre_create_cas_dirs: false,
..Default::default()
};
let result = Cas::<String>::open(db_path, config2);
assert!(result.is_err());
if let Err(LibError::Settings(SettingsError::ValidationFailed(msg))) = result {
assert!(msg.contains("Cannot change num_ops_per_wal from 1000 to 2000"));
} else {
panic!("Expected validation error");
}
Ok(())
}
#[test]
fn test_settings_file() -> Result<()> {
let dir = tempdir()?;
let db_path = dir.path();
let config = Config {
sync_mode: SyncMode::Sync,
num_ops_per_wal: NonZeroU64::new(5000).unwrap(),
pre_create_cas_dirs: true,
..Default::default()
};
{
let _cas = Cas::<String>::open(db_path, config)?;
}
let settings_path = db_path.join("db_settings.json");
assert!(settings_path.exists());
let persister = SettingsPersister::new(settings_path);
let loaded_settings =
persister.load()?.expect("Settings file should exist and be loadable");
assert_eq!(loaded_settings.version, CURRENT_DB_VERSION);
assert!(loaded_settings.dir_tree_is_pre_created);
assert_eq!(loaded_settings.num_ops_per_wal.get(), 5000);
Ok(())
}
#[test]
fn test_future_version_rejected() -> Result<()> {
let dir = tempdir()?;
let settings_path = dir.path().join("db_settings.json");
let future_content = format!(
r#"{{"version":{},"dir_tree_is_pre_created":false,"num_ops_per_wal":1000}}"#,
CURRENT_DB_VERSION + 1
);
std::fs::write(&settings_path, future_content)?;
let persister = SettingsPersister::new(settings_path);
let result = persister.load();
assert!(matches!(
result,
Err(SettingsError::UnsupportedVersion { found, expected })
if found == CURRENT_DB_VERSION + 1 && expected == CURRENT_DB_VERSION
));
Ok(())
}
#[test]
fn test_past_version_rejected() -> Result<()> {
let dir = tempdir()?;
let settings_path = dir.path().join("db_settings.json");
let past_version = CURRENT_DB_VERSION.saturating_sub(1);
let content = format!(
r#"{{"version":{},"dir_tree_is_pre_created":false,"num_ops_per_wal":1000}}"#,
past_version
);
std::fs::write(&settings_path, content)?;
let persister = SettingsPersister::new(settings_path);
let result = persister.load();
assert!(matches!(
result,
Err(SettingsError::UnsupportedVersion { found, expected })
if found == past_version && expected == CURRENT_DB_VERSION
));
Ok(())
}
}