use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use bevy::app::AppExit;
use bevy::ecs::message::MessageReader;
use bevy::ecs::resource::Resource;
use bevy::ecs::system::ResMut;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn};
use crate::events::Event;
use crate::types::script_state::{ScriptState, ScriptStateWrite, WriteOutcome};
use crate::types::wire::{Response, ScriptStateRequest, ScriptStateResponse};
pub const SCRIPT_STATE_FILE_NAME: &str = "script-state.json";
const SUPPORTED_SCRIPT_STATE_VERSION: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
struct SavedScriptState {
version: u32,
state: ScriptState,
}
#[derive(Debug, Default, Resource)]
pub struct ScriptStateStore {
state: ScriptState,
revision: Arc<AtomicU64>,
dirty: bool,
}
impl ScriptStateStore {
#[must_use]
pub fn load() -> Self {
let path = Self::default_file_path();
let Some(saved) = Self::read_file(&path) else {
return Self::default();
};
debug!("Loaded script state from {}", path.display());
Self {
state: saved,
..Self::default()
}
}
fn read_file(path: &Path) -> Option<ScriptState> {
let data = fs::read_to_string(path).ok()?;
match serde_json::from_str::<SavedScriptState>(&data) {
Ok(saved) if saved.version == SUPPORTED_SCRIPT_STATE_VERSION => Some(saved.state),
Ok(saved) => {
warn!(
"Ignoring script state at {}: version {}, expected {SUPPORTED_SCRIPT_STATE_VERSION}",
path.display(),
saved.version
);
None
}
Err(err) => {
warn!(
"Ignoring unreadable script state at {}: {err}",
path.display()
);
None
}
}
}
#[cfg_attr(not(feature = "lua"), allow(dead_code))]
#[must_use]
pub fn revision_handle(&self) -> Arc<AtomicU64> {
Arc::clone(&self.revision)
}
#[cfg_attr(not(feature = "lua"), allow(dead_code))]
#[must_use]
pub fn snapshot(&self) -> ScriptState {
self.state.clone()
}
#[must_use]
pub fn state(&self) -> &ScriptState {
&self.state
}
pub fn apply(&mut self, write: &ScriptStateWrite) -> Result<WriteOutcome, String> {
let outcome = self.state.apply(write)?;
if matches!(outcome, WriteOutcome::Applied { changed: true }) {
self.revision.fetch_add(1, Ordering::Release);
self.dirty = true;
}
Ok(outcome)
}
pub fn save_if_dirty(&mut self) {
if !self.dirty {
return;
}
let path = Self::default_file_path();
match self.write_file(&path) {
Ok(()) => {
self.dirty = false;
debug!("Script state saved to {}", path.display());
}
Err(err) => error!("Failed to save script state to {}: {err}", path.display()),
}
}
fn write_file(&self, path: &Path) -> Result<(), std::io::Error> {
let saved = SavedScriptState {
version: SUPPORTED_SCRIPT_STATE_VERSION,
state: self.state.clone(),
};
let json = serde_json::to_string_pretty(&saved).map_err(std::io::Error::other)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, json)?;
fs::rename(tmp_path, path)?;
Ok(())
}
#[must_use]
pub fn default_file_path() -> PathBuf {
xdg::BaseDirectories::with_prefix("paneru")
.get_state_file(SCRIPT_STATE_FILE_NAME)
.expect("XDG state directory should be available")
}
}
pub fn script_state_handler(
mut messages: MessageReader<Event>,
store: Option<ResMut<ScriptStateStore>>,
) {
let mut store = store;
for message in messages.read() {
let Event::ScriptState {
request,
respond_to,
} = message
else {
continue;
};
let Some(store) = store.as_mut() else {
let _ = respond_to.try_send(error_reply("the script state store is not available"));
continue;
};
let _ = respond_to.try_send(answer(store, request.clone()));
}
}
fn answer(store: &mut ScriptStateStore, request: ScriptStateRequest) -> Response {
match request {
ScriptStateRequest::Get { key } => {
Response::ScriptState(ScriptStateResponse::Value(store.state().get(&key).cloned()))
}
ScriptStateRequest::Write(write) => match store.apply(&write) {
Ok(outcome) => Response::ScriptState(ScriptStateResponse::Write(outcome)),
Err(err) => Response::Error(err),
},
}
}
fn error_reply(message: &str) -> Response {
Response::Error(message.to_string())
}
pub fn periodic_script_state_save(store: Option<ResMut<ScriptStateStore>>) {
if let Some(mut store) = store {
store.save_if_dirty();
}
}
pub fn script_state_cleanup_on_exit(
mut exit_events: MessageReader<AppExit>,
store: Option<ResMut<ScriptStateStore>>,
) {
if exit_events.read().next().is_some()
&& let Some(mut store) = store
{
store.save_if_dirty();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::script_value::ScriptValue;
use serde_json::json;
fn set(key: &str, value: serde_json::Value) -> ScriptStateWrite {
ScriptStateWrite::set(key.to_string(), ScriptValue::from(value))
}
fn applied(store: &mut ScriptStateStore, write: &ScriptStateWrite) -> bool {
match store.apply(write).expect("accepted") {
WriteOutcome::Applied { changed } => changed,
WriteOutcome::Conflict { .. } => panic!("unexpected conflict"),
}
}
#[test]
fn apply_bumps_the_revision_only_on_a_real_change() {
let mut store = ScriptStateStore::default();
let revision = store.revision_handle();
assert!(applied(&mut store, &set("a", json!(1))));
assert_eq!(revision.load(Ordering::Acquire), 1);
assert!(!applied(&mut store, &set("a", json!(1))));
assert_eq!(revision.load(Ordering::Acquire), 1);
assert!(applied(&mut store, &set("a", json!(2))));
assert_eq!(revision.load(Ordering::Acquire), 2);
}
#[test]
fn remove_takes_a_key_out() {
let mut store = ScriptStateStore::default();
applied(&mut store, &set("pad.a", json!(1)));
applied(&mut store, &set("pad.b", json!(2)));
let removed = ScriptStateWrite::remove("pad.a".to_string());
assert!(applied(&mut store, &removed));
assert!(store.state().get("pad.a").is_none());
assert_eq!(
store.state().get("pad.b"),
Some(&ScriptValue::from(json!(2)))
);
assert!(!applied(&mut store, &removed));
}
#[test]
fn a_compare_and_set_lands_only_against_the_value_it_read() {
let mut store = ScriptStateStore::default();
applied(&mut store, &set("counter", json!(1)));
let stale = ScriptStateWrite::compare_and_set(
"counter".to_string(),
Some(ScriptValue::from(json!(0))),
Some(ScriptValue::from(json!(1))),
);
assert_eq!(
store.apply(&stale).expect("accepted"),
WriteOutcome::Conflict {
current: Some(ScriptValue::from(json!(1)))
}
);
let fresh = ScriptStateWrite::compare_and_set(
"counter".to_string(),
Some(ScriptValue::from(json!(1))),
Some(ScriptValue::from(json!(2))),
);
assert_eq!(
store.apply(&fresh).expect("accepted"),
WriteOutcome::Applied { changed: true }
);
assert_eq!(
store.state().get("counter"),
Some(&ScriptValue::from(json!(2)))
);
}
#[test]
fn a_compare_and_set_can_expect_an_absent_key() {
let mut store = ScriptStateStore::default();
let first = ScriptStateWrite::compare_and_set(
"fresh".to_string(),
None,
Some(ScriptValue::from(json!("a"))),
);
assert_eq!(
store.apply(&first).expect("accepted"),
WriteOutcome::Applied { changed: true }
);
assert_eq!(
store.apply(&first).expect("accepted"),
WriteOutcome::Conflict {
current: Some(ScriptValue::from(json!("a")))
}
);
}
#[test]
fn an_empty_key_is_refused() {
let mut store = ScriptStateStore::default();
assert!(store.apply(&set("", json!(1))).is_err());
}
#[test]
fn an_oversized_value_is_refused_and_leaves_the_store_alone() {
let mut store = ScriptStateStore::default();
let huge = "x".repeat(crate::types::script_state::MAX_SERIALISED_BYTES + 1);
assert!(store.apply(&set("big", json!(huge))).is_err());
assert!(store.state().is_empty());
}
#[test]
fn round_trips_through_a_file() {
let path = unique_path("round-trip");
let mut store = ScriptStateStore::default();
applied(&mut store, &set("counter", json!(7)));
store.write_file(&path).expect("written");
let loaded = ScriptStateStore::read_file(&path).expect("read back");
assert_eq!(loaded.get("counter"), Some(&ScriptValue::from(json!(7))));
let _ = fs::remove_file(path);
}
#[test]
fn a_file_from_another_version_is_ignored() {
let path = unique_path("version-mismatch");
let stale = json!({ "version": SUPPORTED_SCRIPT_STATE_VERSION + 1, "state": { "a": 1 } });
fs::write(&path, stale.to_string()).expect("written");
assert!(ScriptStateStore::read_file(&path).is_none());
let _ = fs::remove_file(path);
}
fn unique_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"paneru-script-state-{name}-{}-{}.json",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos()
))
}
}