Skip to content

Commit 087bee6

Browse files
authored
Merge pull request #112 from ryanoneill/feature/session-persistence
Add async session persistence helpers for save/load state
2 parents f593789 + f8c8a79 commit 087bee6

File tree

6 files changed

+217
-1
lines changed

6 files changed

+217
-1
lines changed

Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ tracing = { version = "0.1", optional = true }
4646
arboard = { version = "3", optional = true }
4747
unicode-width = "0.2"
4848
compact_str = "0.8"
49-
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
49+
tokio = { version = "1", features = ["sync", "rt", "macros", "time", "fs"] }
5050
tokio-stream = "0.1"
5151
tokio-util = "0.7"
5252
async-stream = "0.3"
@@ -59,6 +59,7 @@ pretty_assertions = "1.4"
5959
tempfile = "3.24.0"
6060
criterion = { version = "0.5", features = ["html_reports"] }
6161
proptest = "~1.8"
62+
tokio-test = "0.4"
6263
# Note: fastbreak is used for spec validation via CLI, not as a cargo dependency
6364
# Run `fastbreak check` to validate specs/main.fbrk
6465

src/app/command/mod.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,44 @@ impl<M> Command<M> {
218218
}
219219
}
220220

221+
/// Creates a command that saves application state to a JSON file.
222+
///
223+
/// Serializes the state to JSON synchronously, then writes the file
224+
/// asynchronously via `tokio::fs::write`. If serialization fails,
225+
/// returns [`Command::none`]. File write errors are reported to the
226+
/// runtime's error channel (see [`Runtime::take_errors`](crate::Runtime::take_errors)).
227+
///
228+
/// # Example
229+
///
230+
/// ```rust
231+
/// use envision::app::Command;
232+
/// use serde::Serialize;
233+
///
234+
/// #[derive(Serialize)]
235+
/// struct MyState { count: i32 }
236+
///
237+
/// let state = MyState { count: 42 };
238+
/// let cmd: Command<String> = Command::save_state(&state, "/tmp/state.json");
239+
/// ```
240+
#[cfg(feature = "serialization")]
241+
pub fn save_state<S: serde::Serialize>(
242+
state: &S,
243+
path: impl Into<std::path::PathBuf>,
244+
) -> Command<M>
245+
where
246+
M: Send + 'static,
247+
{
248+
let json = match serde_json::to_string(state) {
249+
Ok(json) => json,
250+
Err(_) => return Command::none(),
251+
};
252+
let path = path.into();
253+
Command::try_perform_async(
254+
async move { tokio::fs::write(path, json).await },
255+
|_| None,
256+
)
257+
}
258+
221259
/// Combines multiple commands into one.
222260
pub fn combine(commands: impl IntoIterator<Item = Command<M>>) -> Self {
223261
let mut actions = Vec::new();

src/app/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,17 @@
8181
mod command;
8282
mod command_core;
8383
mod model;
84+
#[cfg(feature = "serialization")]
85+
pub mod persistence;
8486
mod runtime;
8587
mod runtime_core;
8688
mod subscription;
8789
mod update;
8890

8991
pub use command::{BoxedError, Command, CommandHandler};
9092
pub use model::App;
93+
#[cfg(feature = "serialization")]
94+
pub use persistence::load_state;
9195
pub use runtime::{Runtime, RuntimeConfig};
9296
pub use subscription::{
9397
batch, interval_immediate, terminal_events, tick, BatchSubscription, BoxedSubscription,

src/app/persistence/mod.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//! Session persistence helpers for saving and loading application state.
2+
//!
3+
//! This module provides async convenience functions for serializing application
4+
//! state to JSON files and deserializing it back. All functions require the
5+
//! `serialization` feature.
6+
//!
7+
//! # Example
8+
//!
9+
//! ```rust
10+
//! # tokio_test::block_on(async {
11+
//! use envision::app::persistence::load_state;
12+
//! use serde::{Deserialize, Serialize};
13+
//!
14+
//! #[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
15+
//! struct AppState {
16+
//! counter: i32,
17+
//! name: String,
18+
//! }
19+
//!
20+
//! // Save state to a file
21+
//! let dir = std::env::temp_dir().join("envision_doc_test");
22+
//! tokio::fs::create_dir_all(&dir).await.unwrap();
23+
//! let path = dir.join("state.json");
24+
//! let state = AppState { counter: 42, name: "test".into() };
25+
//! let json = serde_json::to_string(&state).unwrap();
26+
//! tokio::fs::write(&path, &json).await.unwrap();
27+
//!
28+
//! // Load it back
29+
//! let loaded: AppState = load_state(&path).await.unwrap();
30+
//! assert_eq!(loaded, state);
31+
//! # tokio::fs::remove_dir_all(&dir).await.unwrap();
32+
//! # });
33+
//! ```
34+
35+
use std::path::Path;
36+
37+
use serde::de::DeserializeOwned;
38+
39+
use crate::error::EnvisionError;
40+
41+
/// Loads application state from a JSON file asynchronously.
42+
///
43+
/// Reads the file at `path` using `tokio::fs`, deserializes it as JSON, and
44+
/// returns the deserialized state. Returns [`EnvisionError::Io`] for file
45+
/// system errors and [`EnvisionError::Config`] for deserialization errors.
46+
///
47+
/// # Example
48+
///
49+
/// ```rust
50+
/// # tokio_test::block_on(async {
51+
/// use envision::app::persistence::load_state;
52+
/// use serde::Deserialize;
53+
///
54+
/// #[derive(Deserialize)]
55+
/// struct MyState {
56+
/// count: i32,
57+
/// }
58+
///
59+
/// // Returns EnvisionError::Io for missing files
60+
/// let result: Result<MyState, _> = load_state("/nonexistent/path.json").await;
61+
/// assert!(result.is_err());
62+
/// # });
63+
/// ```
64+
pub async fn load_state<S: DeserializeOwned>(
65+
path: impl AsRef<Path>,
66+
) -> Result<S, EnvisionError> {
67+
let path = path.as_ref();
68+
let contents = tokio::fs::read_to_string(path).await?;
69+
serde_json::from_str(&contents).map_err(|e| {
70+
EnvisionError::config(
71+
path.display().to_string(),
72+
format!("failed to deserialize state: {}", e),
73+
)
74+
})
75+
}
76+
77+
#[cfg(test)]
78+
mod tests;

src/app/persistence/tests.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
use std::io::Write;
2+
3+
use serde::{Deserialize, Serialize};
4+
use tempfile::NamedTempFile;
5+
6+
use super::*;
7+
8+
#[derive(Serialize, Deserialize, Debug, PartialEq)]
9+
struct TestState {
10+
counter: i32,
11+
name: String,
12+
}
13+
14+
#[tokio::test]
15+
async fn test_load_state_success() {
16+
let state = TestState {
17+
counter: 42,
18+
name: "hello".into(),
19+
};
20+
let json = serde_json::to_string(&state).unwrap();
21+
22+
let mut file = NamedTempFile::new().unwrap();
23+
file.write_all(json.as_bytes()).unwrap();
24+
25+
let loaded: TestState = load_state(file.path()).await.unwrap();
26+
assert_eq!(loaded, state);
27+
}
28+
29+
#[tokio::test]
30+
async fn test_load_state_file_not_found() {
31+
let result: Result<TestState, _> = load_state("/nonexistent/path/state.json").await;
32+
assert!(result.is_err());
33+
let err = result.unwrap_err();
34+
assert!(matches!(err, EnvisionError::Io(_)));
35+
}
36+
37+
#[tokio::test]
38+
async fn test_load_state_invalid_json() {
39+
let mut file = NamedTempFile::new().unwrap();
40+
file.write_all(b"not valid json {{{").unwrap();
41+
42+
let result: Result<TestState, _> = load_state(file.path()).await;
43+
assert!(result.is_err());
44+
let err = result.unwrap_err();
45+
assert!(matches!(err, EnvisionError::Config { .. }));
46+
}
47+
48+
#[tokio::test]
49+
async fn test_load_state_wrong_shape() {
50+
let mut file = NamedTempFile::new().unwrap();
51+
file.write_all(b"{\"x\": 1, \"y\": 2}").unwrap();
52+
53+
let result: Result<TestState, _> = load_state(file.path()).await;
54+
assert!(result.is_err());
55+
let err = result.unwrap_err();
56+
assert!(matches!(err, EnvisionError::Config { .. }));
57+
}
58+
59+
#[tokio::test]
60+
async fn test_load_state_error_message_contains_path() {
61+
let mut file = NamedTempFile::new().unwrap();
62+
file.write_all(b"invalid").unwrap();
63+
let path_str = file.path().display().to_string();
64+
65+
let result: Result<TestState, _> = load_state(file.path()).await;
66+
let err = result.unwrap_err();
67+
let msg = err.to_string();
68+
assert!(
69+
msg.contains(&path_str),
70+
"error message should contain path, got: {}",
71+
msg
72+
);
73+
}
74+
75+
#[tokio::test]
76+
async fn test_load_state_empty_file() {
77+
let file = NamedTempFile::new().unwrap();
78+
79+
let result: Result<TestState, _> = load_state(file.path()).await;
80+
assert!(result.is_err());
81+
let err = result.unwrap_err();
82+
assert!(matches!(err, EnvisionError::Config { .. }));
83+
}

0 commit comments

Comments
 (0)