use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use crate::Off;
use crate::locks::Relock;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StoreError {
#[error("cannot read or write {}: {source}", path.display())]
#[non_exhaustive]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{} does not parse: {message}", path.display())]
#[non_exhaustive]
Parse {
path: PathBuf,
message: String,
},
#[error("the value for {} does not serialise: {message}", path.display())]
#[non_exhaustive]
Serialize {
path: PathBuf,
message: String,
},
}
impl StoreError {
fn io(path: &Path, source: std::io::Error) -> Self {
Self::Io {
path: path.to_owned(),
source,
}
}
}
impl From<StoreError> for Off {
fn from(error: StoreError) -> Self {
Self::new(error.to_string())
}
}
#[derive(Debug, Clone)]
pub struct Store<T> {
path: PathBuf,
_value: PhantomData<T>,
}
impl<T: serde::de::DeserializeOwned + serde::Serialize + Default> Store<T> {
#[must_use]
pub fn new(dir: &Path, name: &str) -> Self {
Self {
path: dir.join(format!("{name}.toml")),
_value: PhantomData,
}
}
pub fn load(&self) -> Result<T, StoreError> {
match std::fs::read_to_string(&self.path) {
Ok(text) => toml::from_str(&text).map_err(|e| StoreError::Parse {
path: self.path.clone(),
message: e.message().to_owned(),
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(StoreError::io(&self.path, e)),
}
}
pub fn save(&self, value: &T) -> Result<(), StoreError> {
write_atomically(&self.path, &self.text(value)?)
}
fn text(&self, value: &T) -> Result<String, StoreError> {
toml::to_string(value).map_err(|e| StoreError::Serialize {
path: self.path.clone(),
message: e.to_string(),
})
}
}
fn write_atomically(path: &Path, text: &str) -> Result<(), StoreError> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| StoreError::io(dir, e))?;
}
let temporary = path.with_extension("toml.tmp");
let written = std::fs::File::create(&temporary).and_then(|mut file| {
std::io::Write::write_all(&mut file, text.as_bytes())?;
file.sync_all()
});
written
.and_then(|()| std::fs::rename(&temporary, path))
.map_err(|e| StoreError::io(path, e))
}
pub const HOLD_FILE: &str = "hold.txt";
pub const WORLDS_DIR: &str = "worlds";
#[derive(Debug, Clone)]
pub struct Hold {
path: PathBuf,
}
impl Hold {
#[must_use]
pub fn new(dir: &Path) -> Self {
Self {
path: dir.join(HOLD_FILE),
}
}
#[must_use]
pub fn for_world(dir: &Path, world: &str) -> Self {
Self::new(&dir.join(WORLDS_DIR).join(world))
}
pub fn set(&self, reason: &str) -> Result<(), StoreError> {
self.write(first_line(reason))
}
pub fn set_text(
&self,
reason: &str,
key: &str,
args: &[(&str, &str)],
) -> Result<(), StoreError> {
let text = crate::i18n::reason::encode(key, args);
self.write(&format!("{}\n{text}", first_line(reason)))
}
fn write(&self, contents: &str) -> Result<(), StoreError> {
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(|e| StoreError::io(dir, e))?;
}
let temporary = self.path.with_extension("txt.tmp");
std::fs::write(&temporary, contents)
.and_then(|()| std::fs::rename(&temporary, &self.path))
.map_err(|e| StoreError::io(&self.path, e))
}
pub fn clear(&self) -> Result<(), StoreError> {
match std::fs::remove_file(&self.path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
Err(StoreError::io(&self.path, e))
}
_ => Ok(()),
}
}
#[must_use]
pub fn reason(&self) -> Option<String> {
std::fs::read_to_string(&self.path)
.ok()
.map(|held| first_line(held.trim_start()).trim().to_owned())
}
#[doc(hidden)]
#[must_use]
pub fn text(&self) -> Option<String> {
let held = std::fs::read_to_string(&self.path).ok()?;
let text = held.trim_start().lines().nth(1)?;
(!text.is_empty()).then(|| text.to_owned())
}
}
fn first_line(text: &str) -> &str {
text.lines().next().unwrap_or_default()
}
#[must_use]
pub fn holds(mod_data: &Path) -> Vec<(String, String)> {
let Ok(entries) = std::fs::read_dir(mod_data) else {
return Vec::new();
};
let mut found: Vec<(String, String)> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let id = entry.file_name().into_string().ok()?;
let reason = Hold::new(&entry.path()).reason()?;
Some((id, reason))
})
.collect();
found.sort();
found
}
#[must_use]
pub fn world_holds(mod_data: &Path, world: &str) -> Vec<(String, String)> {
let Ok(entries) = std::fs::read_dir(mod_data) else {
return Vec::new();
};
let mut found: Vec<(String, String)> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let id = entry.file_name().into_string().ok()?;
let reason = Hold::for_world(&entry.path(), world).reason()?;
Some((id, reason))
})
.collect();
found.sort();
found
}
#[must_use]
pub fn held_worlds(mod_data: &Path) -> Vec<String> {
let Ok(mods) = std::fs::read_dir(mod_data) else {
return Vec::new();
};
let mut worlds: Vec<String> = mods
.filter_map(Result::ok)
.filter_map(|entry| std::fs::read_dir(entry.path().join(WORLDS_DIR)).ok())
.flatten()
.filter_map(Result::ok)
.filter(|world| world.path().join(HOLD_FILE).is_file())
.filter_map(|world| world.file_name().into_string().ok())
.collect();
worlds.sort();
worlds.dedup();
worlds
}
pub(crate) fn world_key(host: crate::Host) -> Option<String> {
let api = host.api();
let mut out = crate::abi::Str::EMPTY;
if !(api.world_key)(host.handle(), &raw mut out).is_ok() {
return None;
}
unsafe { out.as_str() }.map(str::to_owned)
}
pub struct WorldStore<T> {
slot: std::sync::Arc<std::sync::Mutex<WorldSlot<T>>>,
writing: std::sync::Arc<std::sync::Mutex<Option<std::thread::JoinHandle<()>>>>,
}
impl<T> Clone for WorldStore<T> {
fn clone(&self) -> Self {
Self {
slot: std::sync::Arc::clone(&self.slot),
writing: std::sync::Arc::clone(&self.writing),
}
}
}
impl<T> WorldStore<T> {
fn wait_for_writes(&self) -> std::sync::MutexGuard<'_, Option<std::thread::JoinHandle<()>>> {
let mut writing = self.writing.relock();
if let Some(handle) = writing.take()
&& handle.join().is_err()
{
crate::error!("a world store write panicked");
}
writing
}
fn lock(&self) -> std::sync::MutexGuard<'_, WorldSlot<T>> {
self.slot.relock()
}
}
impl<T> core::fmt::Debug for WorldStore<T> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let slot = self.lock();
f.debug_struct("WorldStore")
.field("name", &slot.name)
.field("key", &slot.open.as_ref().map(|open| &open.key))
.finish_non_exhaustive()
}
}
struct WorldSlot<T> {
worlds: PathBuf,
name: String,
open: Option<OpenWorld<T>>,
}
struct OpenWorld<T> {
key: String,
store: Store<T>,
value: T,
changed: bool,
}
fn is_store_name(name: &str) -> bool {
(1..=64).contains(&name.len())
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
}
impl<T> WorldStore<T>
where
T: serde::de::DeserializeOwned + serde::Serialize + Default + Send + 'static,
{
pub(crate) fn new(dir: &Path, name: &str) -> Result<Self, Off> {
if !is_store_name(name) {
return Err(Off::new(format!(
"world store {name:?}: a name is 1 to 64 of [a-z0-9_-]"
)));
}
Ok(Self {
slot: std::sync::Arc::new(std::sync::Mutex::new(WorldSlot {
worlds: dir.join(WORLDS_DIR),
name: name.to_owned(),
open: None,
})),
writing: std::sync::Arc::default(),
})
}
pub(crate) fn open(&self, key: &str) -> Result<(), StoreError> {
self.close()?;
let mut slot = self.lock();
let store = Store::new(&slot.worlds.join(key), &slot.name);
let value = store.load()?;
slot.open = Some(OpenWorld {
key: key.to_owned(),
store,
value,
changed: false,
});
Ok(())
}
pub(crate) fn close(&self) -> Result<(), StoreError> {
drop(self.wait_for_writes());
self.flush()?;
self.lock().open = None;
Ok(())
}
pub fn flush(&self) -> Result<(), StoreError> {
let mut slot = self.lock();
let _writing = self.wait_for_writes();
if let Some(open) = slot.open.as_mut().filter(|open| open.changed) {
open.store.save(&open.value)?;
open.changed = false;
}
Ok(())
}
pub(crate) fn flush_in_background(&self) {
let mut slot = self.lock();
let Some(open) = slot.open.as_mut().filter(|open| open.changed) else {
return;
};
let text = match open.store.text(&open.value) {
Ok(text) => text,
Err(error) => {
crate::error!("{error}");
return;
}
};
open.changed = false;
let path = open.store.path.clone();
let mut writing = self.wait_for_writes();
let spawned = std::thread::Builder::new()
.name("world-store".to_owned())
.spawn(move || {
if let Err(error) = write_atomically(&path, &text) {
crate::error!("{error}");
}
});
match spawned {
Ok(handle) => *writing = Some(handle),
Err(e) => {
open.changed = true;
crate::error!("world store kept for the next save: {e}");
}
}
}
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> Option<R> {
self.lock().open.as_ref().map(|open| f(&open.value))
}
pub fn update<R>(&self, f: impl FnOnce(&mut T) -> R) -> Option<R> {
let mut slot = self.lock();
let open = slot.open.as_mut()?;
open.changed = true;
Some(f(&mut open.value))
}
#[must_use]
pub fn key(&self) -> Option<String> {
self.lock().open.as_ref().map(|open| open.key.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
struct Count {
n: u32,
}
#[test]
fn a_world_s_hold_holds_that_world_alone() {
let data = std::env::temp_dir().join(format!("enshwick-world-hold-{}", std::process::id()));
let _gone = std::fs::remove_dir_all(&data);
let lanterns = data.join("you.lanterns");
Hold::for_world(&lanterns, "world0-aa")
.set("20 lit lanterns")
.unwrap();
Hold::for_world(&data.join("you.altars"), "world1-bb")
.set("17 altars")
.unwrap();
Hold::new(&data.join("you.stacks"))
.set("raised stacks")
.unwrap();
assert_eq!(
world_holds(&data, "world0-aa"),
vec![("you.lanterns".to_owned(), "20 lit lanterns".to_owned())]
);
assert!(world_holds(&data, "world2-cc").is_empty());
assert_eq!(held_worlds(&data), ["world0-aa", "world1-bb"]);
assert_eq!(
holds(&data),
vec![("you.stacks".to_owned(), "raised stacks".to_owned())]
);
Hold::for_world(&lanterns, "world0-aa").clear().unwrap();
assert_eq!(held_worlds(&data), ["world1-bb"]);
let _gone = std::fs::remove_dir_all(&data);
}
#[test]
fn a_text_hold_keeps_its_line_for_readers_without_the_mod_s_texts() {
let data = std::env::temp_dir().join(format!("enshwick-text-hold-{}", std::process::id()));
let _gone = std::fs::remove_dir_all(&data);
let hold = Hold::new(&data.join("you.altars"));
hold.set_text("17 altars\nand more", "hold.altars", &[("altars", "17")])
.unwrap();
assert_eq!(hold.reason().as_deref(), Some("17 altars"));
assert_eq!(hold.text().as_deref(), Some("hold.altars\taltars=17"));
assert_eq!(
holds(&data),
vec![("you.altars".to_owned(), "17 altars".to_owned())]
);
hold.set("17 altars").unwrap();
assert_eq!(hold.text(), None);
let _gone = std::fs::remove_dir_all(&data);
}
#[test]
fn a_hold_file_of_one_plain_line_still_holds() {
let data = std::env::temp_dir().join(format!("enshwick-old-hold-{}", std::process::id()));
let _gone = std::fs::remove_dir_all(&data);
let dir = data.join("you.stacks");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(HOLD_FILE), "raised stacks\n").unwrap();
let hold = Hold::new(&dir);
assert_eq!(hold.reason().as_deref(), Some("raised stacks"));
assert_eq!(hold.text(), None);
assert_eq!(
holds(&data),
vec![("you.stacks".to_owned(), "raised stacks".to_owned())]
);
let _gone = std::fs::remove_dir_all(&data);
}
#[test]
fn a_world_store_writes_its_world_s_file_and_switches_worlds() {
let dir = std::env::temp_dir().join(format!("enshwick-world-store-{}", std::process::id()));
let _gone = std::fs::remove_dir_all(&dir);
let store: WorldStore<Count> = WorldStore::new(&dir, "count").unwrap();
assert_eq!(store.update(|c| c.n += 1), None);
store.open("world0-aa").unwrap();
store.update(|c| c.n = 5);
store.flush().unwrap();
store.open("world1-bb").unwrap();
assert_eq!(store.with(|c| c.n), Some(0));
store.open("world0-aa").unwrap();
assert_eq!(store.with(|c| c.n), Some(5));
assert!(dir.join("worlds/world0-aa/count.toml").exists());
store.update(|c| c.n = 6);
store.flush_in_background();
store.close().unwrap();
let text = std::fs::read_to_string(dir.join("worlds/world0-aa/count.toml")).unwrap();
assert!(text.contains('6'), "{text}");
store.open("world0-aa").unwrap();
store.close().unwrap();
assert_eq!(store.key(), None);
assert!(WorldStore::<Count>::new(&dir, "a/b").is_err());
let _gone = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_flush_lands_after_a_background_write() {
let dir = std::env::temp_dir().join(format!("enshwick-world-flush-{}", std::process::id()));
let _gone = std::fs::remove_dir_all(&dir);
let store: WorldStore<Count> = WorldStore::new(&dir, "count").unwrap();
store.open("world0-aa").unwrap();
let file = dir.join("worlds/world0-aa/count.toml");
for n in 0..20 {
store.update(|c| c.n = n);
store.flush_in_background();
store.update(|c| c.n = n + 100);
store.flush().unwrap();
let saved: Count = toml::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
assert_eq!(saved.n, n + 100);
}
store.close().unwrap();
let _gone = std::fs::remove_dir_all(&dir);
}
}
#[derive(Debug)]
pub struct ModLocal<T>(crate::fresh::Fresh<T>);
impl<T> ModLocal<T> {
#[must_use]
pub const fn new() -> Self {
Self(crate::fresh::Fresh::new())
}
#[must_use]
pub fn get(&self) -> Option<&T> {
self.0.get()
}
pub fn set(&self, value: T) -> Result<(), T> {
self.0.set(value)
}
pub fn get_or_init(&self, init: impl FnOnce() -> T) -> &T {
self.0.get_or_init(init)
}
}
impl<T> Default for ModLocal<T> {
fn default() -> Self {
Self::new()
}
}