use crate::migration::builder::MigrationBuilder;
use crate::store::config::{Disk, FileWritePolicy, StoreConfig, WriteLimits};
use crate::store::facts::Facts;
use crate::store::traits::StoreLayout;
use crate::store::{
CodecFormat, Fallbacks, OpenStore, RuleContext, StorageError, StorageResult, WhenItWillNotRead,
WillNotOpen,
};
use crate::{MigrationReport, Store};
use amethystate_core::path::StorePathError;
use error_stack::{Report, ResultExt};
use std::any::Any;
use std::path::{Path, PathBuf};
use std::sync::Arc;
fn refused_prefix((written, why): (String, StorePathError)) -> OpenStore {
OpenStore::from_store(
Report::new(why)
.change_context(StorageError::Migrate)
.attach(amethystate_core::facts::RawKey(written))
.attach("a migration set was given a prefix that cannot be read as a path"),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Backend {
#[cfg(feature = "redb")]
Redb,
#[cfg(feature = "json")]
Json,
#[cfg(feature = "toml")]
Toml,
#[cfg(feature = "ron")]
Ron,
#[cfg(feature = "sqlite")]
Sqlite,
#[cfg(feature = "memory")]
Memory,
#[cfg(feature = "localstorage")]
LocalStorage,
}
impl Backend {
pub const fn writing(codec: CodecFormat) -> Self {
match codec {
#[cfg(feature = "redb")]
CodecFormat::MessagePack => Backend::Redb,
#[cfg(all(feature = "memory", not(feature = "redb")))]
CodecFormat::MessagePack => Backend::Memory,
#[cfg(feature = "json")]
CodecFormat::Json => Backend::Json,
#[cfg(all(feature = "localstorage", not(feature = "json")))]
CodecFormat::Json => Backend::LocalStorage,
#[cfg(feature = "sqlite")]
CodecFormat::SonicJson => Backend::Sqlite,
#[cfg(feature = "toml")]
CodecFormat::Toml => Backend::Toml,
#[cfg(feature = "ron")]
CodecFormat::Ron => Backend::Ron,
}
}
pub const fn extension(self) -> &'static str {
match self {
#[cfg(feature = "redb")]
Backend::Redb => "redb",
#[cfg(feature = "json")]
Backend::Json => "json",
#[cfg(feature = "toml")]
Backend::Toml => "toml",
#[cfg(feature = "ron")]
Backend::Ron => "ron",
#[cfg(feature = "sqlite")]
Backend::Sqlite => "db",
#[cfg(feature = "memory")]
Backend::Memory => "",
#[cfg(feature = "localstorage")]
Backend::LocalStorage => "",
}
}
pub const fn depth_ceiling(self) -> usize {
match self {
#[cfg(feature = "redb")]
Backend::Redb => 512,
#[cfg(feature = "json")]
Backend::Json => 127,
#[cfg(feature = "toml")]
Backend::Toml => 80,
#[cfg(feature = "ron")]
Backend::Ron => 64,
#[cfg(feature = "sqlite")]
Backend::Sqlite => 127,
#[cfg(feature = "memory")]
Backend::Memory => 512,
#[cfg(feature = "localstorage")]
Backend::LocalStorage => 127,
}
}
pub const fn a_commit_covers_the_whole_store(self) -> bool {
match self {
#[cfg(feature = "redb")]
Backend::Redb => false,
#[cfg(feature = "json")]
Backend::Json => true,
#[cfg(feature = "toml")]
Backend::Toml => true,
#[cfg(feature = "ron")]
Backend::Ron => true,
#[cfg(feature = "sqlite")]
Backend::Sqlite => false,
#[cfg(feature = "memory")]
Backend::Memory => true,
#[cfg(feature = "localstorage")]
Backend::LocalStorage => true,
}
}
pub const fn holds(self) -> Holds {
Holds(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Holds(Backend);
impl Holds {
pub const fn an_integer_past_i64(self) -> bool {
match self.0 {
#[cfg(feature = "memory")]
Backend::Memory => true,
#[cfg(feature = "localstorage")]
Backend::LocalStorage => true,
#[cfg(feature = "redb")]
Backend::Redb => true,
#[cfg(feature = "json")]
Backend::Json => true,
#[cfg(feature = "toml")]
Backend::Toml => false,
#[cfg(feature = "ron")]
Backend::Ron => true,
#[cfg(feature = "sqlite")]
Backend::Sqlite => true,
}
}
pub const fn a_128_bit_integer(self) -> bool {
match self.0 {
#[cfg(feature = "memory")]
Backend::Memory => true,
#[cfg(feature = "localstorage")]
Backend::LocalStorage => true,
#[cfg(feature = "redb")]
Backend::Redb => true,
#[cfg(feature = "json")]
Backend::Json => true,
#[cfg(feature = "toml")]
Backend::Toml => false,
#[cfg(feature = "ron")]
Backend::Ron => false,
#[cfg(feature = "sqlite")]
Backend::Sqlite => true,
}
}
pub const fn an_integer_past_64_bits(self) -> bool {
match self.0 {
#[cfg(feature = "memory")]
Backend::Memory => true,
#[cfg(feature = "localstorage")]
Backend::LocalStorage => false,
#[cfg(feature = "redb")]
Backend::Redb => true,
#[cfg(feature = "json")]
Backend::Json => false,
#[cfg(feature = "toml")]
Backend::Toml => false,
#[cfg(feature = "ron")]
Backend::Ron => false,
#[cfg(feature = "sqlite")]
Backend::Sqlite => true,
}
}
pub const fn non_finite_floats(self) -> bool {
match self.0 {
#[cfg(feature = "memory")]
Backend::Memory => true,
#[cfg(feature = "localstorage")]
Backend::LocalStorage => false,
#[cfg(feature = "redb")]
Backend::Redb => true,
#[cfg(feature = "json")]
Backend::Json => false,
#[cfg(feature = "toml")]
Backend::Toml => true,
#[cfg(feature = "ron")]
Backend::Ron => true,
#[cfg(feature = "sqlite")]
Backend::Sqlite => false,
}
}
pub const fn enums(self) -> bool {
match self.0 {
#[cfg(feature = "memory")]
Backend::Memory => true,
#[cfg(feature = "localstorage")]
Backend::LocalStorage => true,
#[cfg(feature = "redb")]
Backend::Redb => true,
#[cfg(feature = "json")]
Backend::Json => true,
#[cfg(feature = "toml")]
Backend::Toml => true,
#[cfg(feature = "ron")]
Backend::Ron => false,
#[cfg(feature = "sqlite")]
Backend::Sqlite => true,
}
}
pub const fn a_nested_option(self) -> bool {
match self.0 {
#[cfg(feature = "memory")]
Backend::Memory => false,
#[cfg(feature = "localstorage")]
Backend::LocalStorage => false,
#[cfg(feature = "redb")]
Backend::Redb => false,
#[cfg(feature = "json")]
Backend::Json => false,
#[cfg(feature = "toml")]
Backend::Toml => false,
#[cfg(feature = "ron")]
Backend::Ron => true,
#[cfg(feature = "sqlite")]
Backend::Sqlite => false,
}
}
}
impl Backend {
pub fn open_public(
self,
config: StoreConfig,
mset: crate::migration::set::MigrationSet,
) -> StorageResult<(Store, MigrationReport)> {
match self {
#[cfg(feature = "redb")]
Backend::Redb => {
let (s, r) = crate::store::backend::redb::RedbStore::open(config, mset)?;
Ok((Store::from_arc(Arc::new(s)), r))
}
#[cfg(feature = "json")]
Backend::Json => {
let (s, r) = crate::store::backend::text::JsonStore::open(config, mset)?;
Ok((Store::from_arc(Arc::new(s)), r))
}
#[cfg(feature = "toml")]
Backend::Toml => {
let (s, r) = crate::store::backend::text::TomlStore::open(config, mset)?;
Ok((Store::from_arc(Arc::new(s)), r))
}
#[cfg(feature = "ron")]
Backend::Ron => {
let (s, r) = crate::store::backend::text::RonStore::open(config, mset)?;
Ok((Store::from_arc(Arc::new(s)), r))
}
#[cfg(feature = "sqlite")]
Backend::Sqlite => {
let (s, r) = crate::store::backend::sqlite::SqliteStore::open(config, mset)?;
Ok((Store::from_arc(Arc::new(s)), r))
}
#[cfg(feature = "memory")]
Backend::Memory => {
let (s, r) = crate::store::backend::memory::MemoryStore::open(&config, mset)?;
Ok((Store::from_arc(Arc::new(s)), r))
}
#[cfg(feature = "localstorage")]
Backend::LocalStorage => {
let (s, r) =
crate::store::backend::local_storage::LocalStorageStore::open(&config, mset)?;
Ok((Store::from_arc(Arc::new(s)), r))
}
}
}
}
macro_rules! first_enabled_backend {
($feat:literal => $variant:expr $(, $rest_feat:literal => $rest_variant:expr)* $(,)?) => {
{
#[cfg(feature = $feat)]
{ Some($variant) }
#[cfg(not(feature = $feat))]
{ first_enabled_backend!($($rest_feat => $rest_variant),*) }
}
};
() => {
None
};
}
pub(crate) const fn built_in() -> Option<Backend> {
first_enabled_backend! {
"redb" => Backend::Redb,
"sqlite" => Backend::Sqlite,
"json" => Backend::Json,
"toml" => Backend::Toml,
"ron" => Backend::Ron,
"localstorage" => Backend::LocalStorage,
}
}
pub(crate) fn no_engine_built_in() -> Report<StorageError> {
Report::new(StorageError::Open).attach(
"this build has no storage engine: turn on one of the features redb, sqlite, json, toml, \
ron or localstorage",
)
}
#[cfg(any(
feature = "redb",
feature = "sqlite",
feature = "json",
feature = "toml",
feature = "ron",
feature = "localstorage"
))]
pub const fn default_backend() -> Backend {
built_in().expect("a build with an engine has a first one")
}
pub struct StoreBuilder {
backend: Option<Backend>,
config: StoreConfig,
migration_builder: MigrationBuilder,
rule_context: RuleContext,
fallbacks: Fallbacks,
caller_named_extension: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layout {
App,
Native,
ProjectDirs,
}
impl Layout {
pub const fn default_for_build() -> Self {
Self::App
}
}
#[derive(Debug, Clone, Copy)]
pub struct Location;
impl Location {
pub fn app(
self,
app_name: impl AsRef<str>,
config_name: impl AsRef<str>,
) -> StorageResult<PathBuf> {
self.app_under(Layout::default_for_build(), app_name, config_name)
}
pub fn files_at(self, path: impl AsRef<Path>, backend: Backend) -> StoreLayout {
StoreLayout::of(path, backend)
}
pub fn app_under(
self,
layout: Layout,
app_name: impl AsRef<str>,
config_name: impl AsRef<str>,
) -> StorageResult<PathBuf> {
let app_name = app_name.as_ref();
if cfg!(feature = "test-utils") {
let path = std::env::temp_dir()
.join("amethystate-app-dirs")
.join(app_name)
.join(config_name.as_ref());
ensure_parent(&path).attach_with(|| format!("application: {app_name}"))?;
return Ok(path);
}
let args = || etcetera::AppStrategyArgs {
top_level_domain: "rs".to_string(),
author: String::new(),
app_name: app_name.to_string(),
};
let path = match layout {
Layout::App => {
use etcetera::{AppStrategy, app_strategy::choose_app_strategy};
choose_app_strategy(args())
.change_context(StorageError::Open)
.attach("this system has no home directory to put an application's own under")?
.config_dir()
}
Layout::Native => {
use etcetera::{AppStrategy, app_strategy::choose_native_strategy};
choose_native_strategy(args())
.change_context(StorageError::Open)
.attach("this system has no home directory to put an application's own under")?
.config_dir()
}
Layout::ProjectDirs => {
use directories::ProjectDirs;
ProjectDirs::from("rs", "", app_name)
.ok_or_else(|| {
Report::new(StorageError::Open)
.attach("this system has no configuration directory for an application")
})?
.config_dir()
.to_path_buf()
}
}
.join(config_name.as_ref());
ensure_parent(&path).attach_with(|| format!("application: {app_name}"))?;
Ok(path)
}
pub fn beside_the_executable(self, file_name: impl AsRef<Path>) -> StorageResult<PathBuf> {
let exe = std::env::current_exe()
.change_context(StorageError::Open)
.attach("the running executable cannot be located, so nothing is beside it")?;
let dir = exe.parent().ok_or_else(|| {
Report::new(StorageError::Open)
.attach(format!("executable: {}", exe.display()))
.attach("the running executable is not in a directory")
})?;
let path = dir.join(file_name);
ensure_parent(&path).attach_with(|| format!("executable: {}", exe.display()))?;
Ok(path)
}
}
fn ensure_parent(path: &Path) -> StorageResult<()> {
let Some(parent) = path.parent() else {
return Ok(());
};
std::fs::create_dir_all(parent)
.change_context(StorageError::Open)
.attach_with(|| format!("directory: {}", parent.display()))
.attach_store_file(path)
}
impl StoreBuilder {
pub fn new(path: impl Into<PathBuf>) -> Self {
let mut path: PathBuf = path.into();
let caller_named_extension = path.extension().is_some();
let backend = built_in();
if let Some(backend) = backend
&& !caller_named_extension
{
path.set_extension(backend.extension());
}
Self {
backend,
config: StoreConfig::new(path),
migration_builder: MigrationBuilder::default(),
rule_context: RuleContext::default(),
fallbacks: Fallbacks::default(),
caller_named_extension,
}
}
pub fn located(
pick: impl FnOnce(Location) -> StorageResult<PathBuf>,
) -> Result<Self, OpenStore> {
Ok(Self::new(pick(Location).map_err(OpenStore::from_store)?))
}
pub fn disk(mut self, configure: impl FnOnce(Disk) -> Disk) -> Self {
let settled = configure(Disk {
save_debounce: self.config.save_debounce,
watch_debounce: self.config.watch_debounce,
retry_policy: self.config.retry_policy.clone(),
on_persist_failure: self.config.on_persist_failure.clone(),
});
self.config.save_debounce = settled.save_debounce;
self.config.watch_debounce = settled.watch_debounce;
self.config.retry_policy = settled.retry_policy;
self.config.on_persist_failure = settled.on_persist_failure;
self
}
pub fn file_write(
mut self,
configure: impl FnOnce(FileWritePolicy) -> FileWritePolicy,
) -> Self {
self.config.file_write = configure(self.config.file_write);
self
}
pub fn limits(mut self, configure: impl FnOnce(WriteLimits) -> WriteLimits) -> Self {
self.config.limits = configure(std::mem::take(&mut self.config.limits));
self
}
pub fn migrations(mut self, configure: impl FnOnce(&mut MigrationBuilder)) -> WithSteps {
configure(&mut self.migration_builder);
WithSteps(self)
}
pub fn provide<T: Any>(mut self, value: T) -> Self {
self.migration_builder.provide(value);
self
}
pub fn context<T: Any + Send + Sync>(mut self, value: T) -> Self {
self.rule_context.insert(value);
self
}
pub fn parallel_reads(mut self, yes: bool) -> Self {
self.config.parallel_reads = yes;
self
}
pub fn rules(mut self, configure: impl FnOnce(Fallbacks) -> Fallbacks) -> Self {
self.fallbacks = configure(self.fallbacks);
self
}
pub fn when_it_will_not_open(mut self, rule: WillNotOpen) -> Self {
self.config.will_not_open = rule;
self
}
pub fn when_it_will_not_read(mut self, rule: WhenItWillNotRead) -> Self {
self.config.will_not_read = rule;
self
}
pub fn backend(mut self, backend: Backend) -> Self {
self.backend = Some(backend);
if !self.caller_named_extension {
self.config.path.set_extension(backend.extension());
}
self
}
pub fn build(self) -> Result<Store, OpenStore> {
self.opening(Steps::None).map(|(store, _)| store)
}
pub fn migrate(self) -> Result<(Store, MigrationReport), OpenStore> {
self.opening(Steps::All)
}
#[cfg(feature = "memory")]
pub fn in_memory() -> Self {
Self::new(PathBuf::new()).backend(Backend::Memory)
}
#[cfg(feature = "memory")]
pub fn or_in_memory(self) -> OrInMemory {
OrInMemory(self)
}
fn opening(mut self, steps: Steps) -> Result<(Store, MigrationReport), OpenStore> {
let context = Arc::new(std::mem::take(&mut self.rule_context));
self.opening_with(steps, context)
}
fn opening_with(
self,
steps: Steps,
context: Arc<RuleContext>,
) -> Result<(Store, MigrationReport), OpenStore> {
let fallbacks = self.fallbacks;
let (store, report) = self.on_disk(steps)?;
if report.has_failures() {
drop(store);
return Err(OpenStore::migrating(report));
}
Ok((
store.with_context(context).with_fallbacks(fallbacks),
report,
))
}
fn on_disk(mut self, steps: Steps) -> Result<(Store, MigrationReport), OpenStore> {
let backend = self
.backend
.ok_or_else(no_engine_built_in)
.map_err(OpenStore::from_store)?;
let mut migration_set = match steps {
Steps::None => MigrationBuilder::default(),
Steps::All => {
self.migration_builder.collect_codegen();
self.migration_builder
}
}
.into_set()
.map_err(refused_prefix)?;
migration_set.take_on_undeclared(self.fallbacks.on_undeclared);
let (store, report) = backend
.open_public(self.config, migration_set)
.map_err(OpenStore::from_store)?;
report.log_to_tracing();
Ok((store, report))
}
}
enum Steps {
None,
All,
}
#[cfg(feature = "memory")]
pub struct OrInMemory<B = StoreBuilder>(B);
#[cfg(feature = "memory")]
impl OrInMemory {
pub fn build(self) -> (Store, Persistence) {
let (store, _, persistence) = falling_back(self.0, Steps::None);
(store, persistence)
}
pub fn migrate(self) -> (Store, MigrationReport, Persistence) {
falling_back(self.0, Steps::All)
}
}
#[cfg(feature = "memory")]
impl OrInMemory<WithSteps> {
pub fn migrate(self) -> (Store, MigrationReport, Persistence) {
falling_back(self.0.0, Steps::All)
}
}
pub struct WithSteps(StoreBuilder);
impl WithSteps {
pub fn migrations(mut self, configure: impl FnOnce(&mut MigrationBuilder)) -> Self {
configure(&mut self.0.migration_builder);
self
}
pub fn provide<T: Any>(self, value: T) -> Self {
Self(self.0.provide(value))
}
pub fn migrate(self) -> Result<(Store, MigrationReport), OpenStore> {
self.0.migrate()
}
#[cfg(feature = "memory")]
pub fn or_in_memory(self) -> OrInMemory<WithSteps> {
OrInMemory(self)
}
pub(crate) fn into_builder(self) -> StoreBuilder {
self.0
}
}
#[cfg(feature = "memory")]
fn falling_back(mut builder: StoreBuilder, steps: Steps) -> (Store, MigrationReport, Persistence) {
let context = Arc::new(std::mem::take(&mut builder.rule_context));
let fallbacks = builder.fallbacks;
let mut memory = StoreConfig::new(PathBuf::new());
memory.limits = builder.config.limits.clone();
memory.parallel_reads = builder.config.parallel_reads;
match builder.opening_with(steps, context.clone()) {
Ok((store, report)) => (store, report, Persistence::OnDisk),
Err(because) => {
tracing::warn!(
target: "amethystate",
reason = ?because,
"the store would not open, so this run keeps it in memory and writes nothing",
);
let (store, report) = crate::store::backend::memory::MemoryStore::open(
&memory,
crate::migration::set::MigrationSet::default(),
)
.map(|(store, report)| (Store::from_arc(Arc::new(store)), report))
.expect("an empty store in memory has no file to refuse it and no step to fail");
(
store.with_context(context).with_fallbacks(fallbacks),
report,
Persistence::InMemory { because },
)
}
}
}
#[cfg(feature = "memory")]
#[non_exhaustive]
pub enum Persistence {
OnDisk,
InMemory { because: OpenStore },
}
#[cfg(feature = "memory")]
impl Persistence {
pub fn is_in_memory(&self) -> bool {
matches!(self, Self::InMemory { .. })
}
pub fn because(&self) -> Option<&OpenStore> {
match self {
Self::OnDisk => None,
Self::InMemory { because } => Some(because),
}
}
}
#[cfg(feature = "memory")]
impl std::fmt::Debug for Persistence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OnDisk => f.write_str("OnDisk"),
Self::InMemory { because } => write!(f, "InMemory: {because:?}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(all(feature = "redb", feature = "json"))]
#[test]
fn a_defaulted_extension_follows_the_engine_that_is_named() {
let builder = StoreBuilder::new("app/settings").backend(Backend::Json);
assert_eq!(
builder.config.path.extension().and_then(|e| e.to_str()),
Some("json"),
"the engine was named after the path was built, and the path did not follow"
);
}
#[cfg(all(feature = "redb", feature = "json", feature = "toml"))]
#[test]
fn the_last_engine_named_is_the_one_the_path_follows() {
let builder = StoreBuilder::new("app/settings")
.backend(Backend::Json)
.backend(Backend::Toml);
assert_eq!(
builder.config.path.extension().and_then(|e| e.to_str()),
Some("toml")
);
}
#[cfg(all(feature = "redb", feature = "json"))]
#[test]
fn an_extension_the_caller_wrote_is_left_alone() {
let builder = StoreBuilder::new("app/settings.conf").backend(Backend::Json);
assert_eq!(
builder.config.path.extension().and_then(|e| e.to_str()),
Some("conf")
);
}
#[test]
fn an_unnamed_engine_keeps_the_default_extension() {
let builder = StoreBuilder::new("app/settings");
assert_eq!(
builder.config.path.extension().and_then(|e| e.to_str()),
Some(default_backend().extension())
);
}
#[test]
fn a_build_with_no_engine_refuses_the_open_and_names_the_features() {
let mut builder = StoreBuilder::new("app/settings");
builder.backend = None;
let Err(refused) = builder.build() else {
panic!("a store opened with no engine to open it with");
};
let OpenStore::WouldNotOpen { why } = &refused else {
panic!("{refused:?}");
};
let said: Vec<&str> = why
.frames()
.filter_map(|frame| frame.downcast_ref::<&str>().copied())
.collect();
insta::assert_snapshot!(format!("{refused}\n{}", said.join("\n")));
let mut migrating = StoreBuilder::new("app/settings");
migrating.backend = None;
assert!(matches!(
migrating.migrate(),
Err(OpenStore::WouldNotOpen { .. })
));
}
}